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 {
|
||||
reduce,
|
||||
} from '@brief/domain/org-template.machine';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
|
||||
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
|
||||
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||
@@ -34,7 +35,7 @@ const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesj
|
||||
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrgTemplateStore {
|
||||
export class OrgTemplateStore implements PendingSave {
|
||||
private adapter = inject(OrgTemplateAdapter);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private shell = inject(UploadShellService);
|
||||
@@ -108,6 +109,8 @@ export class OrgTemplateStore {
|
||||
if (status === 'resolved' || status === 'local')
|
||||
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
||||
});
|
||||
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
|
||||
registerPendingSave(this);
|
||||
}
|
||||
|
||||
async load() {
|
||||
@@ -130,6 +133,7 @@ export class OrgTemplateStore {
|
||||
this.selectedSubOrgId.set(subOrgId);
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
this.store.dispatch({ tag: 'Loading' });
|
||||
const r = await this.adapter.load(subOrgId);
|
||||
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
||||
@@ -147,7 +151,21 @@ export class OrgTemplateStore {
|
||||
if (this.loaded() === null) return;
|
||||
clearTimeout(this.saveTimer);
|
||||
// ponytail: 600ms debounce, same as BriefStore; 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 s = this.loaded();
|
||||
@@ -178,6 +196,7 @@ export class OrgTemplateStore {
|
||||
this.pendingPublish.set(false);
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||
const r = await this.adapter.publish(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
@@ -193,6 +212,7 @@ export class OrgTemplateStore {
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
@@ -207,6 +227,7 @@ export class OrgTemplateStore {
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
await this.flushSave(); // the proefbrief renders the server's draft
|
||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
|
||||
Reference in New Issue
Block a user