Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13828000d |
No files matched your search
@@ -2081,10 +2081,10 @@ view would leave a wallet one click from deletion.
|
|||||||
with the new chain id to every open tab. A token is tracked only on the
|
with the new chain id to every open tab. A token is tracked only on the
|
||||||
network it was added on, because a contract address names a token on one
|
network it was added on, because a contract address names a token on one
|
||||||
network only. Switching also clears every address's balances. A balance
|
network only. Switching also clears every address's balances. A balance
|
||||||
refresh, by the popup or the background, that started before the switch
|
refresh, by the popup or the background, stores what it fetched only if
|
||||||
stores nothing when it returns, so the previous network's balances never
|
the stored network is still the one it fetched on when it returns, so the
|
||||||
reach the new one; the next refresh fetches the new network's. Choosing
|
previous network's balances never reach the new one. Choosing the network
|
||||||
the network already active changes nothing and sends nothing
|
already active changes nothing and sends nothing
|
||||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||||
`eth_chainId` before being saved)
|
`eth_chainId` before being saved)
|
||||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||||
|
|||||||
+23
-19
@@ -68,15 +68,16 @@ let refreshInFlight = false;
|
|||||||
// the recovery screen: there is no profile left to refresh.
|
// the recovery screen: there is no profile left to refresh.
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|
||||||
// The refresh writes into a copy of the wallets. Its results are copied onto
|
// The refresh writes into a copy of the wallets. Inside the save, under the
|
||||||
// the address records in `state` only if the network it ran on is still the
|
// lock, its results are copied onto the address records in `state` only if the
|
||||||
// active one: a network switch while it ran cleared every address's balances
|
// network it ran on is still the active one, on this page and in the stored
|
||||||
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
|
// record; the background's switch, after the user approves a site's request,
|
||||||
// previous network's must not come back over them
|
// changes only the stored record. A switch while the refresh ran cleared every
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A refresh that returns
|
// address's balances (applyChainSwitchFields() in
|
||||||
// after a switch is dropped and records nothing, so the next tick refreshes the
|
// src/shared/chainSwitchFields.js), and the previous network's must not come
|
||||||
// new network. Results are copied by address, so an address added or removed
|
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped
|
||||||
// meanwhile is left as it is.
|
// 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() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
@@ -94,24 +95,27 @@ async function doRefreshAndRender() {
|
|||||||
pageClosed.signal,
|
pageClosed.signal,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
if (state.networkId !== networkId) return;
|
|
||||||
const refreshed = new Map();
|
const refreshed = new Map();
|
||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
for (const addr of wallet.addresses) {
|
for (const addr of wallet.addresses) {
|
||||||
refreshed.set(addr.address, addr);
|
refreshed.set(addr.address, addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const wallet of state.wallets) {
|
await saveState((stored) => {
|
||||||
for (const addr of wallet.addresses) {
|
if (state.networkId !== networkId) return false;
|
||||||
const got = refreshed.get(addr.address);
|
if (stored.networkId !== networkId) return false;
|
||||||
if (!got) continue;
|
for (const wallet of state.wallets) {
|
||||||
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
for (const addr of wallet.addresses) {
|
||||||
if (got[key] !== undefined) addr[key] = got[key];
|
const got = refreshed.get(addr.address);
|
||||||
|
if (!got) continue;
|
||||||
|
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
||||||
|
if (got[key] !== undefined) addr[key] = got[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
state.lastBalanceRefresh = Date.now();
|
||||||
state.lastBalanceRefresh = Date.now();
|
return true;
|
||||||
await saveState();
|
});
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
// Every call site fires this and walks away — the boot below, the ten
|
||||||
|
|||||||
+14
-7
@@ -467,11 +467,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||||
// does not own goes on being whatever its last loadState() saw, same as
|
// does not own goes on being whatever its last loadState() saw, same as
|
||||||
// before this fix; only the persisted record is guaranteed current.
|
// before this fix; only the persisted record is guaranteed current.
|
||||||
async function saveStateOnce() {
|
async function saveStateOnce(beforeWrite) {
|
||||||
// A copy, so what this save compares and writes is the page's state as it
|
|
||||||
// stood when the save began. A change made while it waits on storage is
|
|
||||||
// left for the next save, which compares against this copy.
|
|
||||||
const current = structuredClone(snapshotPersisted());
|
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
// The record in storage right now is about to be merged into and written
|
// The record in storage right now is about to be merged into and written
|
||||||
// back, so it is validated exactly like a load validates it. Without this,
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
@@ -485,6 +481,12 @@ async function saveStateOnce() {
|
|||||||
// come from storage in its loaded (self-healed) shape. See
|
// come from storage in its loaded (self-healed) shape. See
|
||||||
// normalizePersisted() in persistedState.js.
|
// normalizePersisted() in persistedState.js.
|
||||||
const fresh = normalizePersisted(result.autistmask);
|
const fresh = normalizePersisted(result.autistmask);
|
||||||
|
if (beforeWrite && !beforeWrite(fresh)) return;
|
||||||
|
|
||||||
|
// A copy, so what this save compares and writes is the page's state as it
|
||||||
|
// stood once the stored record was read. A change made while it waits on
|
||||||
|
// the write is left for the next save, which compares against this copy.
|
||||||
|
const current = structuredClone(snapshotPersisted());
|
||||||
|
|
||||||
const merged = { ...fresh };
|
const merged = { ...fresh };
|
||||||
for (const key of PERSISTED_FIELDS) {
|
for (const key of PERSISTED_FIELDS) {
|
||||||
@@ -581,8 +583,13 @@ function reportSaveFailure(err) {
|
|||||||
// asked for at once; they take the lock in the order they were asked, and each
|
// asked for at once; they take the lock in the order they were asked, and each
|
||||||
// compares the page's state as it stands when its turn begins against the
|
// compares the page's state as it stands when its turn begins against the
|
||||||
// baseline the save before it left.
|
// baseline the save before it left.
|
||||||
function saveState() {
|
//
|
||||||
const turn = withLock("autistmask", saveStateOnce);
|
// `beforeWrite`, when given, is called inside the lock with the stored record
|
||||||
|
// as just read, before this page's state is compared against it. It may change
|
||||||
|
// `state`, and this save writes that change. It returns whether the save goes
|
||||||
|
// ahead: when it returns false, nothing is written.
|
||||||
|
function saveState(beforeWrite) {
|
||||||
|
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
// one. The returned promise still rejects, so a caller that DOES await
|
// one. The returned promise still rejects, so a caller that DOES await
|
||||||
// keeps its own error handling.
|
// keeps its own error handling.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// A balance refresh the popup started on one network, and that returns after
|
// A balance refresh the popup started on one network, and that returns after
|
||||||
// the user switched to another in Settings, writes nothing: the switch cleared
|
// the network was switched to another, writes nothing: the switch cleared every
|
||||||
// every address's balances, and the previous network's must not come back over
|
// address's balances, and the previous network's must not come back over them
|
||||||
// them (https://git.eeqj.de/sneak/AutistMask/issues/577). Driven through the
|
// (https://git.eeqj.de/sneak/AutistMask/issues/577). The switch is made either
|
||||||
// real popup over a stored profile on Ethereum Mainnet. The same refresh in the
|
// by the user in this popup's Settings, or by the background after the user
|
||||||
// background is in tests/backgroundStateIsolation.test.js.
|
// approved a site's request, which changes only the stored record. Driven
|
||||||
|
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||||
|
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -11,28 +13,18 @@ const {
|
|||||||
unversionedValidProfile,
|
unversionedValidProfile,
|
||||||
TOKEN_ADDRESS,
|
TOKEN_ADDRESS,
|
||||||
} = require("./support/popupBoot");
|
} = require("./support/popupBoot");
|
||||||
|
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanupPopup();
|
cleanupPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Picks `networkId` in the Settings network selector, as the user does.
|
// Starts the ten-second refresh and holds it open. The balance stand-in writes
|
||||||
async function chooseNetwork(page, networkId) {
|
// Ethereum Mainnet's balances in place, on the records handed in, as the real
|
||||||
const select = page.node("settings-network");
|
// refreshBalances() does, once the returned function is called. Resolves with
|
||||||
select.value = networkId;
|
// that function and the refresh still running.
|
||||||
for (const fn of select.listeners.change) await fn();
|
async function holdRefresh(page) {
|
||||||
await page.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a refresh that returns after a switch writes nothing, and the next one runs on the new network", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
|
|
||||||
// The ten-second refresh, held open until answer() is called. It writes
|
|
||||||
// Ethereum Mainnet's balances in place, on the records handed in, as the
|
|
||||||
// real refreshBalances() does.
|
|
||||||
let answer;
|
let answer;
|
||||||
refreshBalances.mockImplementationOnce(async (wallets) => {
|
refreshBalances.mockImplementationOnce(async (wallets) => {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -52,19 +44,46 @@ test("a refresh that returns after a switch writes nothing, and the next one run
|
|||||||
});
|
});
|
||||||
const ticking = page.tick();
|
const ticking = page.tick();
|
||||||
await page.settle();
|
await page.settle();
|
||||||
|
return {
|
||||||
|
finish: async () => {
|
||||||
|
answer();
|
||||||
|
await ticking;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await page.click("btn-settings");
|
// Picks `networkId` in the Settings network selector, as the user does.
|
||||||
await chooseNetwork(page, "sepolia");
|
async function chooseNetwork(page, networkId) {
|
||||||
answer();
|
const select = page.node("settings-network");
|
||||||
await ticking;
|
select.value = networkId;
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await page.settle();
|
||||||
|
}
|
||||||
|
|
||||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
// The stored record holds none of Ethereum Mainnet's balances, and no refresh
|
||||||
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
// recorded as done.
|
||||||
|
function expectNothingStored(page) {
|
||||||
const stored = page.storage.read("autistmask");
|
const stored = page.storage.read("autistmask");
|
||||||
expect(stored.networkId).toBe("sepolia");
|
expect(stored.networkId).toBe("sepolia");
|
||||||
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
||||||
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
expect(stored.lastBalanceRefresh).toBe(0);
|
expect(stored.lastBalanceRefresh).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a refresh that returns after a switch in Settings writes nothing, and the next one runs on the new network", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
await page.click("btn-settings");
|
||||||
|
await chooseNetwork(page, "sepolia");
|
||||||
|
await refresh.finish();
|
||||||
|
|
||||||
|
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
||||||
|
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
|
expectNothingStored(page);
|
||||||
|
|
||||||
await page.click("btn-settings-back");
|
await page.click("btn-settings-back");
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
@@ -77,3 +96,24 @@ test("a refresh that returns after a switch writes nothing, and the next one run
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a refresh that returns after the background switched the network writes nothing", async () => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { saveState } = require("../src/shared/state");
|
||||||
|
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
// The background writes the switch to Sepolia, as its updateState() does
|
||||||
|
// once the user approves a site's request. This page is not told.
|
||||||
|
const record = page.storage.read("autistmask");
|
||||||
|
applyChainSwitchFields(record, "sepolia");
|
||||||
|
page.storage.write("autistmask", record);
|
||||||
|
await refresh.finish();
|
||||||
|
|
||||||
|
expectNothingStored(page);
|
||||||
|
|
||||||
|
// Nor does a later save by this page, such as every navigation makes.
|
||||||
|
await saveState();
|
||||||
|
|
||||||
|
expectNothingStored(page);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user