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:
+531
-184
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
|||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||||
|
import { provideUnloadFlush } from '@shared/application/pending-saves';
|
||||||
|
|
||||||
registerLocaleData(localeNl);
|
registerLocaleData(localeNl);
|
||||||
|
|
||||||
@@ -51,5 +52,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||||
provideRouteFocus(),
|
provideRouteFocus(),
|
||||||
|
provideUnloadFlush(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { ShellComponent } from '@shared/layout/shell/shell.component';
|
import { ShellComponent } from '@shared/layout/shell/shell.component';
|
||||||
import { authGuard, capabilityGuard } from '@auth/auth.guard';
|
import { authGuard, capabilityGuard } from '@auth/auth.guard';
|
||||||
|
import { flushPendingGuard } from '@shared/application/pending-saves';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -32,23 +33,28 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'registreren',
|
path: 'registreren',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
// Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).
|
||||||
|
canDeactivate: [flushPendingGuard],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage),
|
import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'herregistratie',
|
path: 'herregistratie',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
canDeactivate: [flushPendingGuard],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage),
|
import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'intake',
|
path: 'intake',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
canDeactivate: [flushPendingGuard],
|
||||||
loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage),
|
loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'brief',
|
path: 'brief',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
canDeactivate: [flushPendingGuard],
|
||||||
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,6 +63,7 @@ export const routes: Routes = [
|
|||||||
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
||||||
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
||||||
canActivate: [capabilityGuard('orgtemplate:edit')],
|
canActivate: [capabilityGuard('orgtemplate:edit')],
|
||||||
|
canDeactivate: [flushPendingGuard],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -301,3 +301,28 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
|
|||||||
expect(store.lastError()).toBe('geweigerd');
|
expect(store.lastError()).toBe('geweigerd');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||||
|
const okSave = () =>
|
||||||
|
vi.fn(() => Promise.resolve({ ok: true, value: filledView } as Result<string, BriefView>));
|
||||||
|
|
||||||
|
it('flushes a pending debounced edit immediately and clears the pending flag', async () => {
|
||||||
|
const save = okSave();
|
||||||
|
const store = await loadedStore({ save });
|
||||||
|
expect(store.hasPendingSave()).toBe(false);
|
||||||
|
|
||||||
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||||
|
expect(store.hasPendingSave()).toBe(true); // 600ms debounce armed, not yet fired
|
||||||
|
|
||||||
|
await store.flushPending();
|
||||||
|
expect(save).toHaveBeenCalledTimes(1); // no timer wait needed
|
||||||
|
expect(store.hasPendingSave()).toBe(false); // timer consumed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no edit is pending', async () => {
|
||||||
|
const save = okSave();
|
||||||
|
const store = await loadedStore({ save });
|
||||||
|
await store.flushPending();
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
|||||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||||
import { uploadContentUrl } from '@shared/upload/upload.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
|
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||||
instead of a busy boolean + a nullable error sitting side by side. */
|
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.
|
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefStore {
|
export class BriefStore implements PendingSave {
|
||||||
private adapter = inject(BriefAdapter);
|
private adapter = inject(BriefAdapter);
|
||||||
private previewAdapter = inject(LetterPreviewAdapter);
|
private previewAdapter = inject(LetterPreviewAdapter);
|
||||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||||
@@ -188,12 +189,32 @@ export class BriefStore {
|
|||||||
this.future.set([]);
|
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 saveTimer?: ReturnType<typeof setTimeout>;
|
||||||
private scheduleSave() {
|
private scheduleSave() {
|
||||||
if (!this.canEdit()) return;
|
if (!this.canEdit()) return;
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
// 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() {
|
private async flushSave() {
|
||||||
const b = this.brief();
|
const b = this.brief();
|
||||||
@@ -217,6 +238,7 @@ export class BriefStore {
|
|||||||
async resetDemo() {
|
async resetDemo() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
const r = await this.adapter.reset();
|
const r = await this.adapter.reset();
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -268,6 +290,7 @@ export class BriefStore {
|
|||||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
await this.flushSave();
|
await this.flushSave();
|
||||||
const r = await action();
|
const r = await action();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
reduce,
|
reduce,
|
||||||
} from '@brief/domain/org-template.machine';
|
} from '@brief/domain/org-template.machine';
|
||||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
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. */
|
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
|
||||||
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
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`.
|
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class OrgTemplateStore {
|
export class OrgTemplateStore implements PendingSave {
|
||||||
private adapter = inject(OrgTemplateAdapter);
|
private adapter = inject(OrgTemplateAdapter);
|
||||||
private uploadAdapter = inject(UploadAdapter);
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
private shell = inject(UploadShellService);
|
private shell = inject(UploadShellService);
|
||||||
@@ -108,6 +109,8 @@ export class OrgTemplateStore {
|
|||||||
if (status === 'resolved' || status === 'local')
|
if (status === 'resolved' || status === 'local')
|
||||||
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
||||||
});
|
});
|
||||||
|
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
|
||||||
|
registerPendingSave(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
@@ -130,6 +133,7 @@ export class OrgTemplateStore {
|
|||||||
this.selectedSubOrgId.set(subOrgId);
|
this.selectedSubOrgId.set(subOrgId);
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
this.store.dispatch({ tag: 'Loading' });
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
const r = await this.adapter.load(subOrgId);
|
const r = await this.adapter.load(subOrgId);
|
||||||
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
||||||
@@ -147,7 +151,21 @@ export class OrgTemplateStore {
|
|||||||
if (this.loaded() === null) return;
|
if (this.loaded() === null) return;
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
|
// 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() {
|
private async flushSave() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
@@ -178,6 +196,7 @@ export class OrgTemplateStore {
|
|||||||
this.pendingPublish.set(false);
|
this.pendingPublish.set(false);
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
const r = await this.adapter.publish(s.subOrgId);
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -193,6 +212,7 @@ export class OrgTemplateStore {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
@@ -207,6 +227,7 @@ export class OrgTemplateStore {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = undefined;
|
||||||
await this.flushSave(); // the proefbrief renders the server's draft
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
@@ -96,4 +96,39 @@ describe('createDraftSync', () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||||
|
it('hasPendingSave reflects an armed debounce timer', () => {
|
||||||
|
const { draftSync, snap } = setup({
|
||||||
|
create: vi.fn().mockResolvedValue('a1'),
|
||||||
|
syncDraft: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
expect(draftSync.hasPendingSave()).toBe(false);
|
||||||
|
|
||||||
|
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick(); // the effect arms the 600ms timer
|
||||||
|
expect(draftSync.hasPendingSave()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPending writes the pending draft immediately, before the debounce fires', async () => {
|
||||||
|
const create = vi.fn().mockResolvedValue('a1');
|
||||||
|
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { draftSync, snap } = setup({ create, syncDraft });
|
||||||
|
|
||||||
|
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
|
||||||
|
tick();
|
||||||
|
await draftSync.flushPending();
|
||||||
|
|
||||||
|
expect(syncDraft).toHaveBeenCalledTimes(1); // no timer advance needed
|
||||||
|
expect(draftSync.hasPendingSave()).toBe(false); // timer consumed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPending is a no-op when nothing is pending', async () => {
|
||||||
|
const syncDraft = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { draftSync } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft });
|
||||||
|
|
||||||
|
await draftSync.flushPending();
|
||||||
|
expect(syncDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DestroyRef, effect, inject } from '@angular/core';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
import { registerPendingSave } from '@shared/application/pending-saves';
|
||||||
import type {
|
import type {
|
||||||
SubmitApplicationRequest,
|
SubmitApplicationRequest,
|
||||||
SubmitApplicationResponse,
|
SubmitApplicationResponse,
|
||||||
@@ -104,11 +105,26 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
const snap = deps.snapshot(); // tracked: fires on every machine change
|
const snap = deps.snapshot(); // tracked: fires on every machine change
|
||||||
if (!snap) return;
|
if (!snap) return;
|
||||||
if (timer) clearTimeout(timer);
|
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));
|
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
|
// 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.
|
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||||
const load = (linked: string): Promise<void> => {
|
const load = (linked: string): Promise<void> => {
|
||||||
@@ -142,6 +158,11 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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
|
/** 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). */
|
existing Concept; else start fresh (a Concept is created on first progress). */
|
||||||
async resume() {
|
async resume() {
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user