feat(fp): flush pending autosave before navigation/unload
Close the last-mile autosave gap: a debounced edit made in the final <600ms before leaving a page was lost — the wizard draft-sync timer is cleared on destroy without flushing, and root stores keep an armed timer the teardown ignores. New `shared/application/pending-saves.ts`: a root `PendingSaves` registry every autosave owner joins (BriefStore, OrgTemplateStore, each createDraftSync). Two seams flush through it — `flushPendingGuard` (CanDeactivate, on the five autosave routes) awaits the pending write before an in-app route change; a `beforeunload` handler (provideUnloadFlush) fires it best-effort and raises the browser's native unsaved-changes prompt. ponytail: the HTTP seam is Angular HttpClient (no keepalive/sendBeacon), so a hard-close flush can't be guaranteed — hence the prompt; upgrade path noted in a comment. Each owner now nulls its timer handle on fire so `hasPendingSave()` is accurate, and exposes `flushPending()`. Verified live against the running stack: navigating away 91ms after a keystroke (well inside the debounce) fires one PUT /brief before the route changes; a dirty reload raises the prompt, a clean reload does not. FE lint / check:tokens / 299 tests (+11) / build / build-storybook green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
|
||||
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||
instead of a busy boolean + a nullable error sitting side by side. */
|
||||
@@ -38,7 +39,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore {
|
||||
export class BriefStore implements PendingSave {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||
@@ -188,12 +189,32 @@ export class BriefStore {
|
||||
this.future.set([]);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Register so the CanDeactivate guard / beforeunload handler can flush a pending
|
||||
// debounced edit before navigation or unload (see pending-saves.ts).
|
||||
registerPendingSave(this);
|
||||
}
|
||||
|
||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||
private scheduleSave() {
|
||||
if (!this.canEdit()) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
||||
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = undefined;
|
||||
void this.flushSave();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
/** True while a debounced edit hasn't been written yet (PendingSave). */
|
||||
hasPendingSave = () => this.saveTimer !== undefined;
|
||||
/** Flush a pending debounced save now and await it; no-op when nothing is pending. */
|
||||
async flushPending() {
|
||||
if (this.saveTimer === undefined) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
await this.flushSave();
|
||||
}
|
||||
private async flushSave() {
|
||||
const b = this.brief();
|
||||
@@ -217,6 +238,7 @@ export class BriefStore {
|
||||
async resetDemo() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
const r = await this.adapter.reset();
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
if (r.ok) {
|
||||
@@ -268,6 +290,7 @@ export class BriefStore {
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
if (!r.ok) {
|
||||
|
||||
Reference in New Issue
Block a user