diff --git a/README.md b/README.md index c2c7bcc..614f3d4 100644 --- a/README.md +++ b/README.md @@ -558,7 +558,8 @@ three lines: 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 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: diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js index 671edbf..fc02656 100644 --- a/src/popup/views/addressDetail.js +++ b/src/popup/views/addressDetail.js @@ -146,11 +146,12 @@ async function loadTransactions(address) { } // If the network changed during the lookups above, or the screen now - // shows another address, nothing is saved or drawn. See - // src/shared/storedSettings.js. + // shows another address, 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. if ( - state.networkId !== networkId || !(await storedNetworkIs(networkId)) || + state.networkId !== networkId || $("address-line").dataset.full !== address ) { return; @@ -187,8 +188,8 @@ async function loadTransactions(address) { // If the network changed, or the screen now shows another address, // the failure is not shown. if ( - state.networkId !== networkId || !(await storedNetworkIs(networkId)) || + state.networkId !== networkId || $("address-line").dataset.full !== address ) { return; diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 42234a9..1ee9a75 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -241,11 +241,13 @@ async function loadTransactions(address, tokenId) { } // 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. if ( - state.networkId !== networkId || !(await storedNetworkIs(networkId)) || + state.networkId !== networkId || $("address-token-line").dataset.full !== address || state.selectedToken !== tokenId ) { @@ -283,8 +285,8 @@ async function loadTransactions(address, tokenId) { // If the network changed, or the screen now shows another address or // token, the failure is not shown. if ( - state.networkId !== networkId || !(await storedNetworkIs(networkId)) || + state.networkId !== networkId || $("address-token-line").dataset.full !== address || state.selectedToken !== tokenId ) { diff --git a/src/popup/views/home.js b/src/popup/views/home.js index d9fea96..93680fe 100644 --- a/src/popup/views/home.js +++ b/src/popup/views/home.js @@ -100,6 +100,9 @@ function renderActiveAddress() { let homeTxs = []; +// The network that `home-tx-list` holds rows or a message for. +let listNetworkId = null; + function renderHomeTxList(ctx) { const list = $("home-tx-list"); if (!list) return; @@ -186,6 +189,19 @@ async function loadHomeTxs(ctx) { if (allAddresses.length === 0) return; 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 = + '
Loading...
'; + } + } + const filters = { hideSpoofedSymbols: state.hideSpoofedSymbols, hideLowHolderTokens: state.hideLowHolderTokens, @@ -206,10 +222,12 @@ async function loadHomeTxs(ctx) { const results = await Promise.all(fetches); // 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 ( - state.networkId !== networkId || - !(await storedNetworkIs(networkId)) + !(await storedNetworkIs(networkId)) || + state.networkId !== networkId ) { return; } @@ -258,8 +276,8 @@ async function loadHomeTxs(ctx) { log.errorf("loadHomeTxs failed:", e.message); // If the network changed, the failure is not shown. if ( - state.networkId !== networkId || - !(await storedNetworkIs(networkId)) + !(await storedNetworkIs(networkId)) || + state.networkId !== networkId ) { return; } diff --git a/tests/historyAfterNetworkSwitch.test.js b/tests/historyAfterNetworkSwitch.test.js index 8fc11c7..94efba0 100644 --- a/tests/historyAfterNetworkSwitch.test.js +++ b/tests/historyAfterNetworkSwitch.test.js @@ -1,6 +1,8 @@ // Home, AddressDetail and AddressToken drop a transaction history answer that // 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 // 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) { const el = { id, @@ -409,6 +426,91 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => { expect(drawn).toContain("tx-row"); 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.", + ); + }, + ); + + // 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) => { diff --git a/tests/popupFollowsNetworkSwitch.test.js b/tests/popupFollowsNetworkSwitch.test.js index a82bb39..bf1b293 100644 --- a/tests/popupFollowsNetworkSwitch.test.js +++ b/tests/popupFollowsNetworkSwitch.test.js @@ -137,6 +137,60 @@ test.each([ 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. test("a switch in Settings is not taken a second time when its save is reported", async () => { const page = await bootOnAddress();