fix: Home's list drops the previous network's rows after a network switch (closes #718)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A network switch left the previous network's rows or failure message in Home's
list until the new network's answer landed. Home now replaces them with
"Loading..." when it first draws on a new network. A redraw on the same network
keeps its rows.

Home, AddressDetail and AddressToken compared the page's network before reading
storage. A switch made in Settings could change the page during that read, and
the answer was still drawn. They now compare after the read, for an answer and
for a failure.

Model: opus-5-5
This commit was merged in pull request #738.
This commit is contained in:
2026-11-03 09:54:35 +01:00
parent 1ea0730a81
commit 49713e1cdb
6 changed files with 226 additions and 14 deletions
+2 -1
View File
@@ -558,7 +558,8 @@ three lines:
A contract creation has no counterparty. Its last line reads "This transaction A contract creation has no counterparty. Its last line reads "This transaction
creates a new contract. It has no recipient." While "Use Blockscout" is off, the creates a new contract. It has no recipient." While "Use Blockscout" is off, the
list shows only the line "No transaction history is shown, because "Use list shows only the line "No transaction history is shown, because "Use
Blockscout" is off in Settings." Blockscout" is off in Settings." After a network switch, the list shows
"Loading..." in place of the previous network's rows or failure message.
Transitions: Transitions:
+5 -4
View File
@@ -146,11 +146,12 @@ async function loadTransactions(address) {
} }
// If the network changed during the lookups above, or the screen now // If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See // shows another address, nothing is saved or drawn. The page's network
// src/shared/storedSettings.js. // is compared after the read of storage, because a switch made on this
// page can land during that read. See src/shared/storedSettings.js.
if ( if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) || !(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-line").dataset.full !== address $("address-line").dataset.full !== address
) { ) {
return; return;
@@ -187,8 +188,8 @@ async function loadTransactions(address) {
// If the network changed, or the screen now shows another address, // If the network changed, or the screen now shows another address,
// the failure is not shown. // the failure is not shown.
if ( if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) || !(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-line").dataset.full !== address $("address-line").dataset.full !== address
) { ) {
return; return;
+5 -3
View File
@@ -241,11 +241,13 @@ async function loadTransactions(address, tokenId) {
} }
// If the network changed during the lookups above, or the screen now // If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See // shows another address or token, nothing is saved or drawn. The page's
// network is compared after the read of storage, because a switch made
// on this page can land during that read. See
// src/shared/storedSettings.js. // src/shared/storedSettings.js.
if ( if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) || !(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-token-line").dataset.full !== address || $("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId state.selectedToken !== tokenId
) { ) {
@@ -283,8 +285,8 @@ async function loadTransactions(address, tokenId) {
// If the network changed, or the screen now shows another address or // If the network changed, or the screen now shows another address or
// token, the failure is not shown. // token, the failure is not shown.
if ( if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) || !(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-token-line").dataset.full !== address || $("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId state.selectedToken !== tokenId
) { ) {
+25 -5
View File
@@ -100,6 +100,9 @@ function renderActiveAddress() {
let homeTxs = []; let homeTxs = [];
// The network that `home-tx-list` holds rows or a message for.
let listNetworkId = null;
function renderHomeTxList(ctx) { function renderHomeTxList(ctx) {
const list = $("home-tx-list"); const list = $("home-tx-list");
if (!list) return; if (!list) return;
@@ -186,6 +189,19 @@ async function loadHomeTxs(ctx) {
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const networkId = state.networkId; const networkId = state.networkId;
// After a network switch the list still holds the previous network's rows
// or failure message. They go here, because every way onto Home draws Home
// first. A redraw on the same network keeps its rows.
if (listNetworkId !== networkId) {
listNetworkId = networkId;
const list = $("home-tx-list");
if (list) {
list.innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
}
}
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
@@ -206,10 +222,12 @@ async function loadHomeTxs(ctx) {
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or // If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js. // drawn. The page's network is compared after the read of storage,
// because a switch made on this page can land during that read. See
// src/shared/storedSettings.js.
if ( if (
state.networkId !== networkId || !(await storedNetworkIs(networkId)) ||
!(await storedNetworkIs(networkId)) state.networkId !== networkId
) { ) {
return; return;
} }
@@ -250,6 +268,7 @@ async function loadHomeTxs(ctx) {
merged = filtered.transactions; merged = filtered.transactions;
merged.sort((a, b) => b.blockNumber - a.blockNumber); merged.sort((a, b) => b.blockNumber - a.blockNumber);
homeTxs = merged.slice(0, 25); homeTxs = merged.slice(0, 25);
listNetworkId = networkId;
renderHomeTxList(ctx); renderHomeTxList(ctx);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
@@ -258,11 +277,12 @@ async function loadHomeTxs(ctx) {
log.errorf("loadHomeTxs failed:", e.message); log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown. // If the network changed, the failure is not shown.
if ( if (
state.networkId !== networkId || !(await storedNetworkIs(networkId)) ||
!(await storedNetworkIs(networkId)) state.networkId !== networkId
) { ) {
return; return;
} }
listNetworkId = networkId;
const list = $("home-tx-list"); const list = $("home-tx-list");
if (list) { if (list) {
list.innerHTML = list.innerHTML =
+135 -1
View File
@@ -1,6 +1,8 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that // Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no // lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). // fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). Home's list
// shows nothing of the previous network once it is drawn after a switch
// (https://git.eeqj.de/sneak/AutistMask/issues/718).
// //
// Most tests hold the history request open, switch the network or what the // Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal // screen shows, and then let the answer land. The views run against a minimal
@@ -154,6 +156,21 @@ function holdLock() {
}; };
} }
// Every read of storage waits until the returned function is called. Each read
// then answers with what storage holds at that moment, the oldest read first.
function holdReads() {
const read = storage.local.get;
const waiting = [];
storage.local.get = (key) =>
new Promise((resolve) => {
waiting.push(() => resolve(read(key)));
});
return () => {
storage.local.get = read;
for (const answer of waiting) answer();
};
}
function makeElement(id) { function makeElement(id) {
const el = { const el = {
id, id,
@@ -409,6 +426,123 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
expect(drawn).toContain("tx-row"); expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn); expect(node(screen.listId).innerHTML).toBe(drawn);
}); });
// The user picks Sepolia in Settings, and the switch reads storage. The
// answer lands during that read, so the screen's own read of storage still
// finds Ethereum Mainnet. The switch changes the page before the screen's
// read ends. The answer is the transfer of ETH alone, so nothing is saved,
// and only a check after the screen's read can drop the answer.
test("an answer that lands during the first read of a switch made in Settings adds nothing", async () => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
const answerReads = holdReads();
const switching = onChainSwitch("sepolia");
await settle();
land();
await settle();
answerReads();
await switching;
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails during the first read of a switch made in Settings shows no message", async () => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
const answerReads = holdReads();
const switching = onChainSwitch("sepolia");
await settle();
fail();
await settle();
answerReads();
await switching;
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
});
describe("Home's list", () => {
test.each([
{ what: "rows", hold: holdHistory, shown: "tx-row" },
{
what: "failure message",
hold: holdFailure,
shown: "Failed to load transactions.",
},
])(
"shows the previous network's $what neither before nor after the new network's answer lands",
async ({ hold, shown }) => {
const answer = hold();
HOME.open();
answer();
await settle();
expect(node(HOME.listId).innerHTML).toContain(shown);
await onChainSwitch("sepolia");
const land = holdHistory([]);
HOME.open();
await settle();
expect(node(HOME.listId).innerHTML).toContain("Loading...");
land();
await settle();
expect(node(HOME.listId).innerHTML).toContain(
"No transactions found.",
);
},
);
// The user leaves Ethereum Mainnet with a request waiting, comes back, and
// that request lands. The list then holds Ethereum Mainnet's answer, so the
// next draw on Sepolia must replace it.
test.each([
{ what: "rows", hold: holdHistory },
{ what: "failure message", hold: holdFailure },
])(
"shows no $what of an older request that lands after a switch back",
async ({ hold }) => {
const answer = hold();
HOME.open();
await onChainSwitch("sepolia");
holdHistory();
HOME.open();
await onChainSwitch("mainnet");
answer();
await settle();
await onChainSwitch("sepolia");
holdHistory();
HOME.open();
await settle();
const list = node(HOME.listId).innerHTML;
expect(list).toContain("Loading...");
expect(list).not.toContain("tx-row");
expect(list).not.toContain("Failed to load transactions.");
},
);
// Each balance refresh draws Home again, so the rows must not blink.
test("keeps its rows while a redraw on the same network waits for the answer", async () => {
fetchRecentTransactions.mockImplementationOnce(async () => history());
HOME.open();
await settle();
const drawn = node(HOME.listId).innerHTML;
holdHistory();
HOME.open();
await settle();
expect(drawn).toContain("tx-row");
expect(node(HOME.listId).innerHTML).toBe(drawn);
});
}); });
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => { describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
+54
View File
@@ -137,6 +137,60 @@ test.each([
expect(page.text("total-value")).toBe("0.0000 SepoliaETH"); expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
}); });
// A request on Ethereum Mainnet fails, so Home's list holds the failure message.
// The list shows it neither before nor after Sepolia's answer lands.
test.each([
{
how: "saved by the background",
switchToSepolia: (page) => backgroundSwitch(page, "sepolia"),
},
{
how: "made in Settings",
switchToSepolia: async (page) => {
await page.click("btn-settings");
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.click("btn-settings-back");
},
},
])(
"Home's list drops the previous network's message after a switch $how",
async ({ switchToSepolia }) => {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
const {
fetchRecentTransactions,
} = require("../src/shared/transactions");
fetchRecentTransactions.mockRejectedValueOnce(
new Error("Blockscout is down"),
);
await page.tick();
expect(page.node("home-tx-list").innerHTML).toContain(
"Failed to load transactions.",
);
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve([]);
}),
);
await switchToSepolia(page);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.node("home-tx-list").innerHTML).toContain("Loading...");
land();
await page.settle();
expect(page.node("home-tx-list").innerHTML).toContain(
"No transactions found.",
);
},
);
// The report of the switch's own save names the network the page already has. // The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => { test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress(); const page = await bootOnAddress();