From 9822a45d9ab0c86405fde3bb4b487ca8be1e5a1d Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 1 Jul 2026 18:35:24 +0200 Subject: [PATCH] Fix: late draft-resume no longer clobbers in-progress wizard input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draftSync.resume() does async network work and dispatched Seed on completion, which could land after the user's first action and reset the machine (cursor + fields) — the "click Volgende twice" symptom. Guard centrally: applyResume() skips when the user already has progress (snapshot() != null) or there's nothing to restore. onResume is now only ever called with a real draft on a pristine machine, so the three wizard callbacks drop the dead `?? initial`. Co-Authored-By: Claude Opus 4.8 --- .../herregistratie-wizard.component.ts | 2 +- .../intake-wizard/intake-wizard.component.ts | 2 +- src/app/registratie/application/draft-sync.ts | 24 +++++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 75324f8..b341fb1 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -102,7 +102,7 @@ export class HerregistratieWizardComponent { const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!); return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds }; }, - onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as WizardState | null) ?? initial }), + onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }), enabled: () => this.seed() === initial, }); diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 3cb4523..d38f43d 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -131,7 +131,7 @@ export class IntakeWizardComponent { if (s.tag !== 'Answering' || !hasProgress(s)) return null; return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] }; }, - onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as IntakeState | null) ?? initial }), + onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }), enabled: () => this.seed() === initial, }); diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts index 3af49ef..9b9d049 100644 --- a/src/app/registratie/application/draft-sync.ts +++ b/src/app/registratie/application/draft-sync.ts @@ -18,8 +18,9 @@ export interface DraftSyncDeps { type: AanvraagType; /** The machine snapshot while it's worth persisting; null when not (pristine/done). */ snapshot: () => DraftSnapshot | null; - /** Seed the machine from a resumed draft (null → start fresh). Called once, on init. */ - onResume: (draft: unknown | null) => void; + /** Seed the machine from a resumed draft. Called at most once, on init, and ONLY + with a real draft on a still-pristine machine — see `applyResume`. */ + onResume: (draft: unknown) => void; /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */ enabled: () => boolean; } @@ -64,6 +65,15 @@ export function createDraftSync(deps: DraftSyncDeps) { return ensuring; }; + // Apply a resumed draft only when it's safe to: a late lookup must never clobber + // progress the user already made while it was in flight, and "start fresh" needs no + // dispatch (the machine already starts fresh). snapshot() is non-null once the user + // has real progress. + const applyResume = (draft: unknown | null) => { + if (draft == null || deps.snapshot() != null) return; + deps.onResume(draft); + }; + const flush = async () => { const snap = deps.snapshot(); if (!snap) return; @@ -92,14 +102,14 @@ export function createDraftSync(deps: DraftSyncDeps) { .then((dto) => { if (dto.status && dto.status.tag !== 'Concept') { id = undefined; - deps.onResume(null); + applyResume(null); return; } - deps.onResume(dto.draft ?? null); + applyResume(dto.draft ?? null); }) .catch(() => { id = undefined; - deps.onResume(null); // unknown/deleted id → start fresh + applyResume(null); // unknown/deleted id → start fresh }); }; @@ -121,7 +131,7 @@ export function createDraftSync(deps: DraftSyncDeps) { resumeGate = new Promise((r) => (release = r)); try { if (!active()) { - deps.onResume(null); + applyResume(null); return; } const linked = route!.snapshot.queryParamMap.get('aanvraag'); @@ -136,7 +146,7 @@ export function createDraftSync(deps: DraftSyncDeps) { void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true }); return; } - deps.onResume(null); + applyResume(null); } finally { release(); }