backgroundRefresh() mutates state.wallets in place (addr.balance/ensName/
tokenBalances via refreshBalances()), so a whole-field diff on `wallets`
marked the entire array "changed" the moment any balance moved and wrote
back background's own copy -- loaded before its multi-second network
round trip -- clobbering a wallet another page added, or resurrecting one
another page deleted, in that window. That is DoD item 2 on the issue,
still unmet by the prior whole-field merge.
`wallets` is now merged structurally: by wallet identity (xpub for
hd/xprv wallets, address for key wallets, both already enforced unique),
then by address identity within each wallet. A leaf background actually
changed applies on top of storage's current copy; membership added or
removed by another page applies independently, since it no longer
collides with `wallets` as a single field. Every other persisted field
stays a whole-field diff -- no code path mutates them the way
backgroundRefresh() mutates wallets, so there is no matching defect to
fix there.
Every extension page (the toolbar popup, a dApp approval window, the
background's backgroundRefresh()) holds its own in-memory `state`, loaded
once, and showView() saves on every navigation. saveState() wrote the
entire state blob, so any second page that saved overwrote whatever
another page had written since -- a whole wallet, name, addresses and
encrypted secret included, with no attacker and no unusual input.
saveState() now re-reads storage, diffs the persisted fields against a
deep-cloned baseline snapshot taken at this page's last
loadState()/saveState(), and writes only the fields that differ. Every
other field is carried forward from storage in its loaded-and-normalized
shape (normalizePersisted(), shared with loadState()), so a legacy or
malformed record a load has always self-healed in memory keeps getting
written back even on a save that touched something unrelated.
showView() fires saveState() without awaiting it, so two saves from the
SAME page can be in flight at once; a FIFO queue serializes them.
Deliberately not done, a documented deviation from the plan on the
issue: the live `state` of a field this page does not own is not
rehydrated from what another page wrote, only the persisted record is.
Adopting a concurrently-written value into `state` reintroduced the same
clobber one page later, under the fire-and-forget saveState() calling
convention every view uses -- caught red by tests/txStatus.test.js.
Two writers of the same field still resolve last-writer-wins, documented
at the merge point.
tests/stateMerge.test.js covers both required cases against the real
state.js and showView(): a save from a page loaded before a wallet was
added elsewhere, and the approval-window reproduction from the issue.
Both were confirmed failing against the prior full-blob write before
this fix landed.