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:
eho
2026-07-21 16:29:09 +02:00
co-authored by Claude Opus 4.8
parent e5edae4970
commit 645fad088e
10 changed files with 841 additions and 189 deletions
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves';
/** A fake autosave owner whose pending-ness and flush are controllable. */
function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType<typeof vi.fn> } {
return {
hasPendingSave: () => pending,
flushPending: vi.fn().mockResolvedValue(undefined),
};
}
describe('PendingSaves registry', () => {
it('hasPending is true only while some registered owner has a pending write', () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
reg.register(idle);
expect(reg.hasPending()).toBe(false);
const dirty = fakeOwner(true);
reg.register(dirty);
expect(reg.hasPending()).toBe(true);
});
it('unregister removes an owner so it no longer counts', () => {
const reg = new PendingSaves();
const dirty = fakeOwner(true);
const off = reg.register(dirty);
expect(reg.hasPending()).toBe(true);
off();
expect(reg.hasPending()).toBe(false);
});
it('flushAll flushes only the pending owners', async () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
const dirty = fakeOwner(true);
reg.register(idle);
reg.register(dirty);
await reg.flushAll();
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
expect(idle.flushPending).not.toHaveBeenCalled();
});
it('flushAll awaits every owner and swallows a rejected flush', async () => {
const reg = new PendingSaves();
const failing = fakeOwner(true);
failing.flushPending.mockRejectedValue(new Error('save failed'));
const ok = fakeOwner(true);
reg.register(failing);
reg.register(ok);
await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects
expect(ok.flushPending).toHaveBeenCalledTimes(1);
});
});
describe('flushPendingGuard', () => {
it('flushes then allows navigation when a write is pending', async () => {
const dirty = fakeOwner(true);
TestBed.configureTestingModule({});
const reg = TestBed.inject(PendingSaves);
reg.register(dirty);
const result = TestBed.runInInjectionContext(() =>
// the guard ignores its route args
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
await expect(result).resolves.toBe(true);
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
});
it('allows navigation immediately when nothing is pending', () => {
TestBed.configureTestingModule({});
TestBed.inject(PendingSaves).register(fakeOwner(false));
const result = TestBed.runInInjectionContext(() =>
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
expect(result).toBe(true); // synchronous, not a Promise
});
});
@@ -0,0 +1,85 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
Injectable,
inject,
} from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
/**
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
* this app have different lifetimes — root singleton stores (`BriefStore`,
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
* organisms — so both register here instead of the guard/unload handler needing to know
* which page or store owns the pending write.
*/
export interface PendingSave {
/** True while a debounced edit hasn't been written to the backend yet. */
hasPendingSave(): boolean;
/** Flush that pending write now and await it. No-op when nothing is pending. */
flushPending(): Promise<void>;
}
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
`beforeunload` handler flush through this — one seam, both callers. */
@Injectable({ providedIn: 'root' })
export class PendingSaves {
private readonly owners = new Set<PendingSave>();
/** Register an owner; returns an unregister function. */
register(owner: PendingSave): () => void {
this.owners.add(owner);
return () => this.owners.delete(owner);
}
hasPending(): boolean {
return [...this.owners].some((o) => o.hasPendingSave());
}
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
flush is swallowed (a failed autosave surfaces its own error state; navigation must
not be blocked by it). */
async flushAll(): Promise<void> {
await Promise.allSettled(
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
);
}
}
/** Register the current injection context's owner for the life of its `DestroyRef`.
Call from a constructor or field initializer (root store, or `createDraftSync`). */
export function registerPendingSave(owner: PendingSave): void {
const unregister = inject(PendingSaves).register(owner);
inject(DestroyRef).onDestroy(unregister);
}
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
then allow navigation. Awaitable, so the write lands before the page tears down (which
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
guarantee of effort, not a gate. */
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
const pending = inject(PendingSaves);
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
};
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
needs to be guaranteed. */
export function provideUnloadFlush() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const pending = inject(PendingSaves);
window.addEventListener('beforeunload', (e) => {
if (!pending.hasPending()) return;
void pending.flushAll();
e.preventDefault();
e.returnValue = '';
});
},
};
}