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:
@@ -2,6 +2,7 @@ import { DestroyRef, effect, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { registerPendingSave } from '@shared/application/pending-saves';
|
||||
import type {
|
||||
SubmitApplicationRequest,
|
||||
SubmitApplicationResponse,
|
||||
@@ -104,11 +105,26 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
const snap = deps.snapshot(); // tracked: fires on every machine change
|
||||
if (!snap) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => void flush(), DEBOUNCE_MS);
|
||||
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void flush();
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
||||
|
||||
// Flush a pending debounced draft write before an in-app route change / unload (see
|
||||
// pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.
|
||||
const hasPendingSave = () => timer !== undefined;
|
||||
const flushPending = async () => {
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
await flush();
|
||||
};
|
||||
registerPendingSave({ hasPendingSave, flushPending });
|
||||
|
||||
// Attach to a specific Concept id and seed the machine from its draft. A non-Concept
|
||||
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||
const load = (linked: string): Promise<void> => {
|
||||
@@ -142,6 +158,11 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
};
|
||||
|
||||
return {
|
||||
/** True while a debounced draft write is still pending (PendingSave). */
|
||||
hasPendingSave,
|
||||
/** Flush the pending draft write now and await it; no-op when nothing is pending. */
|
||||
flushPending,
|
||||
|
||||
/** Resolve the initial state: a `?aanvraag` link wins; else resume this type's
|
||||
existing Concept; else start fresh (a Concept is created on first progress). */
|
||||
async resume() {
|
||||
|
||||
Reference in New Issue
Block a user