The work package form is ~4,700px tall and had no autosave and no unsaved-work
guard. The only beforeunload listener in the app was analytics dwell tracking, so
a mis-click, a closed tab or a crash lost everything typed since the last Save.
html/wp-autosave.js separates three things this app was conflating:
THE DRAFT what you have typed. Saved locally, continuously, by this file.
THE RECORD what you explicitly Saved, which goes to the project.
THE OUTBOX project-data.js, which gets the RECORD to the server reliably.
This module owns the first only and never writes to the server. A draft is
"unfinished work this browser is holding for you"; pushing unfinished work into a
shared project is a different feature with different consequences.
The guard fires only when the form differs from what was loaded. "Do not fire the
guard when nothing has changed" is in the task because a dialog that appears on
every exit gets clicked through within a day, and is then worse than no dialog.
WIRED: the creator's package form and the SOP wizard's state. Both autosave on a
1200ms debounce, on section/step change, and on visibilitychange - the last being
what makes recovery survive a killed tab, since a crash never fires beforeunload.
The wizard's guard is ADDED alongside trackStepDwell, not in place of it; both
fire and the analytics one does not preventDefault.
THREE BUGS FOUND WHILE BUILDING THIS, all by the probe rather than by reading:
- Dirtiness cannot be "does the form match savedPackages". Those records come
back from the server through serverToPkg() in a LEANER shape - 264 characters
against the form's 1,820 - so a freshly loaded, untouched form differed from
its own record and every single exit would have prompted. Dirtiness is now
measured against a baseline snapshot taken when the form is populated.
- currentView is 'Work Package Form', not 'Form'. My first guard compared
against 'Form' and therefore returned false always: autosave was wired,
registered, and quietly dead. T4.2 had also introduced currentView='Form' in
its popstate handler; that is fixed here too, since it would have broken this
and anything else keyed off the view.
- settled() has to cancel the pending debounce. A save follows typing, so there
is nearly always a write already scheduled; without cancelling it the write
lands a second later and resurrects the draft that was just settled - and the
next load offers to recover work that is already saved.
VERIFICATION. tests/autosave_check.py, 23 checks, all passing:
- typing autosaves unprompted; the draft holds what was typed; it is scoped to
project AND package; and it does NOT appear in the outbox
- an untouched form is not dirty and arms no guard; a typed-in one does
- the draft survives a killed tab and is OFFERED back rather than applied
silently, saying plainly that nothing reached the project, via role=status
- restoring puts the work back in the form
- an explicit save settles the draft, and the probe asserts the save actually
landed first - otherwise the rest of that section proves nothing
- a simulated QuotaExceededError is reported as 'failed' with its reason, not
swallowed; a silent autosave failure is a safety net that is not there
- trackStepDwell still records an event
Two notes for later waves. The fixture's SOP defines no WP types, so
savePackage() legitimately refuses until the probe supplies one - worth knowing
before someone reads that as a bug. And native dialogs hung the headless browser
twice more in this task; with 79 of them in the app, any restore or save path
that reaches one will hang a test rather than fail visibly. S6/S7 in wave 9.
browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, url_state 23/23.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
7.0 KiB
JavaScript
173 lines
7.0 KiB
JavaScript
/* Autosave, unsaved-work guard and draft recovery — S2 / T4.3.
|
|
---------------------------------------------------------------------------
|
|
The work package form is roughly 4,700px tall and there was no autosave and no
|
|
unsaved-work guard on it. The only beforeunload listener in the app was
|
|
analytics dwell tracking (work-package-suite-app.js), so a mis-click, a closed
|
|
tab or a crash lost everything typed since the last explicit Save.
|
|
|
|
Three separate things, often confused:
|
|
|
|
THE DRAFT what you have typed, saved here, locally, continuously.
|
|
THE RECORD what you have explicitly Saved, which goes to the server.
|
|
THE OUTBOX project-data.js, which gets the RECORD to the server reliably.
|
|
|
|
This file owns the first only. It never writes to the server: a draft is
|
|
"unfinished work this browser is holding for you", and pushing unfinished work
|
|
to a shared project is a different feature with different consequences.
|
|
|
|
The guard fires only when the form actually differs from the record. A dialog
|
|
that appears on every exit gets clicked through within a day and is worse than
|
|
no dialog, which is why `Do not: fire the guard when nothing has changed` is
|
|
part of the task rather than a nicety.
|
|
*/
|
|
(function (window, document) {
|
|
'use strict';
|
|
|
|
var DRAFT_PREFIX = 'wp_draft::';
|
|
var DEBOUNCE_MS = 1200;
|
|
|
|
var reg = null; // the single registered surface for this page
|
|
var timer = null;
|
|
var status = { state: 'idle', at: null, error: null };
|
|
var statusSubs = [];
|
|
var guardInstalled = false;
|
|
|
|
function now() { return new Date().toISOString(); }
|
|
|
|
function emit() {
|
|
statusSubs.forEach(function (fn) {
|
|
try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {}
|
|
});
|
|
}
|
|
|
|
function setStatus(state, extra) {
|
|
status = Object.assign({ state: state, at: status.at, error: null }, extra || {});
|
|
emit();
|
|
}
|
|
|
|
function draftKey(id) { return DRAFT_PREFIX + id; }
|
|
|
|
function readDraft(id) {
|
|
try { return JSON.parse(window.localStorage.getItem(draftKey(id)) || 'null'); }
|
|
catch (e) { return null; }
|
|
}
|
|
|
|
function writeDraft(id, payload) {
|
|
// A failed write is the one case that MUST be surfaced rather than swallowed:
|
|
// it means the safety net is not there, and the user is the only one who can
|
|
// act on that (close a tab, free some quota, save explicitly now).
|
|
window.localStorage.setItem(draftKey(id), JSON.stringify(payload));
|
|
}
|
|
|
|
function clearDraft(id) {
|
|
try { window.localStorage.removeItem(draftKey(id)); } catch (e) {}
|
|
}
|
|
|
|
function save(reason) {
|
|
if (!reg) return false;
|
|
if (!reg.isDirty()) { setStatus('idle'); return false; }
|
|
setStatus('saving');
|
|
try {
|
|
writeDraft(reg.draftId(), {
|
|
v: 1, at: now(), reason: reason || 'debounce',
|
|
entity: reg.id, data: reg.collect(),
|
|
});
|
|
setStatus('saved', { at: now() });
|
|
return true;
|
|
} catch (e) {
|
|
// QuotaExceededError, private-mode storage, a locked profile.
|
|
setStatus('failed', { error: (e && e.message) || String(e) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function schedule(reason) {
|
|
if (!reg) return;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () { save(reason || 'debounce'); }, DEBOUNCE_MS);
|
|
}
|
|
|
|
function installGuard() {
|
|
if (guardInstalled) return;
|
|
guardInstalled = true;
|
|
// ADDED alongside the analytics dwell listener, never replacing it. Both fire;
|
|
// beforeunload supports multiple listeners and the analytics one does not
|
|
// preventDefault, so the two do not interact.
|
|
window.addEventListener('beforeunload', function (e) {
|
|
if (!reg || !reg.isDirty()) return undefined; // nothing unsaved: stay silent
|
|
save('unload'); // one last draft write
|
|
e.preventDefault();
|
|
e.returnValue = ''; // required by Chrome
|
|
return '';
|
|
});
|
|
// A crash or a killed tab never fires beforeunload. `visibilitychange` to
|
|
// hidden does, and it is the last reliable moment to write the draft - which
|
|
// is what makes the recovery survive "kill the tab and reopen".
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.visibilityState === 'hidden') save('hidden');
|
|
});
|
|
}
|
|
|
|
window.WPAutosave = {
|
|
/* Register the page's editable surface.
|
|
|
|
id stable name for the surface, e.g. 'wp-form'
|
|
scope element to watch for input/change (defaults to document)
|
|
draftId () => storage id, usually project + entity so two projects do
|
|
not share one draft
|
|
collect () => a JSON-serialisable snapshot of the form
|
|
isDirty () => does the form differ from the last explicitly saved record
|
|
restore (data) => put a recovered snapshot back into the form
|
|
*/
|
|
register: function (opts) {
|
|
reg = {
|
|
id: opts.id,
|
|
scope: opts.scope || document,
|
|
draftId: opts.draftId || function () { return opts.id; },
|
|
collect: opts.collect,
|
|
isDirty: opts.isDirty,
|
|
restore: opts.restore,
|
|
};
|
|
reg.scope.addEventListener('input', function () { schedule('input'); });
|
|
reg.scope.addEventListener('change', function () { schedule('change'); });
|
|
installGuard();
|
|
return window.WPAutosave;
|
|
},
|
|
|
|
// Autosave now rather than on the debounce - for a step or section change,
|
|
// where the user has visibly moved on and expects the previous part kept.
|
|
flush: function (reason) { clearTimeout(timer); return save(reason || 'flush'); },
|
|
|
|
isDirty: function () { return !!(reg && reg.isDirty()); },
|
|
status: function () { return Object.assign({}, status); },
|
|
onStatus: function (fn) {
|
|
statusSubs.push(fn);
|
|
try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {}
|
|
return function () {
|
|
var i = statusSubs.indexOf(fn);
|
|
if (i >= 0) statusSubs.splice(i, 1);
|
|
};
|
|
},
|
|
|
|
/* Recovery. Returns the stored draft for an id, or null. The caller decides
|
|
whether to offer it - only it knows whether the draft is actually newer
|
|
than the record, and offering to restore work that is already saved is its
|
|
own kind of alarming. */
|
|
peek: function (id) { return readDraft(id); },
|
|
discard: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); },
|
|
|
|
/* Called after an explicit Save succeeded: the record now holds this work, so
|
|
the draft is no longer protecting anything and keeping it would make the
|
|
next load offer to "recover" work that is already saved.
|
|
|
|
clearTimeout FIRST. A save typically follows typing, so there is usually a
|
|
debounced write already scheduled; without cancelling it, that write lands
|
|
a second after the draft was cleared and resurrects it — and the next load
|
|
offers to recover work that is already saved, which is the exact thing this
|
|
method exists to prevent. */
|
|
settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); },
|
|
|
|
_key: draftKey,
|
|
};
|
|
})(window, document);
|