fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already reads the stored record
and runs the same check loadState() runs at open; the popup now sends a save
refused by that check to the recovery screen and stops its ten-second refresh,
while any other failed save keeps the "NOT SAVED" banner and the screen it is
on. The recovery screen ignores a second request to show it, so a save that
was in flight does not clear an export or a typed confirmation. The test
harness records the refresh loop so a test can run one tick of it.

Model: opus-5-5
This commit is contained in:
2026-10-04 19:50:14 +00:00
parent 3b713809c8
commit bdbe999c05
7 changed files with 130 additions and 22 deletions
+21 -2
View File
@@ -50,6 +50,10 @@ function renderWalletList() {
let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -155,7 +159,22 @@ async function init() {
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner);
//
// Every save first reads the stored record and refuses it with the same
// check loadState() runs below. So a record that becomes unreadable while
// the popup is open is found by the next save, a navigation or the
// ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Any other failed save
// is a read or write that failed, and gets the banner without changing
// the screen.
onSaveFailure((e) => {
if (e instanceof StateUnusableError) {
clearInterval(refreshTimer);
stateRecovery.show(e);
} else {
showSaveFailureBanner(e);
}
});
try {
await loadState();
} catch (e) {
@@ -244,7 +263,7 @@ async function init() {
renderWalletList();
restoreView();
doRefreshAndRender();
setInterval(doRefreshAndRender, 10000);
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ const VIEWS = [
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the
// state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
// or saved — but it is listed so that every view-hiding loop covers it.
"state-recovery",
];
+10 -2
View File
@@ -3,8 +3,11 @@
// Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by
// the time this runs, loadState() has REFUSED, deliberately, and reading the
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
// the time this runs, the stored record has been REFUSED, deliberately: at
// open loadState() refused it and reading the singleton throws
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it, so every further save fails too
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
//
// So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it
@@ -170,6 +173,11 @@ function wire() {
* refused, or its sentence.
*/
function show(problem) {
// Already up: a later save that trips over the same record, such as a
// refresh that was in flight when the screen went up, must not clear what
// the user has exported or typed here.
if (!$("view-state-recovery").classList.contains("hidden")) return;
const sentence =
(problem && (problem.problem || problem.message)) || String(problem);