@daltonr/pathwrite-core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -11,6 +11,25 @@ export type PathData = Record<string, unknown>;
11
11
  */
12
12
  export type FieldErrors = Record<string, string | undefined>;
13
13
 
14
+ /**
15
+ * The return type of a `canMoveNext` or `canMovePrevious` guard.
16
+ *
17
+ * - `true` — allow navigation
18
+ * - `{ allowed: false }` — block silently (no message)
19
+ * - `{ allowed: false, reason: "..." }` — block with a message; the shell surfaces
20
+ * this as `snapshot.blockingError` between the step content and the nav buttons
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * canMoveNext: async ({ data }) => {
25
+ * const result = await checkEligibility(data.applicantId);
26
+ * if (!result.eligible) return { allowed: false, reason: result.reason };
27
+ * return true;
28
+ * }
29
+ * ```
30
+ */
31
+ export type GuardResult = true | { allowed: false; reason?: string };
32
+
14
33
  export interface SerializedPathState {
15
34
  version: 1;
16
35
  pathId: string;
@@ -29,15 +48,15 @@ export interface SerializedPathState {
29
48
  stepEntryData?: PathData;
30
49
  stepEnteredAt?: number;
31
50
  }>;
32
- _isNavigating: boolean;
51
+ _status: PathStatus;
33
52
  }
34
53
 
35
54
  /**
36
55
  * The interface every path state store must implement.
37
56
  *
38
- * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
57
+ * `HttpStore` from `@daltonr/pathwrite-store` is the reference
39
58
  * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
40
- * implements this interface and works with `httpPersistence` and
59
+ * implements this interface and works with `persistence` and
41
60
  * `restoreOrStart` without any other changes.
42
61
  */
43
62
  export interface PathStore {
@@ -105,8 +124,8 @@ export interface PathStep<TData extends PathData = PathData> {
105
124
  title?: string;
106
125
  meta?: Record<string, unknown>;
107
126
  shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
108
- canMoveNext?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
109
- canMovePrevious?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
127
+ canMoveNext?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
128
+ canMovePrevious?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
110
129
  /**
111
130
  * Returns a map of field ID → error message explaining why the step is not
112
131
  * yet valid. The shell displays these messages below the step content (labeled
@@ -195,6 +214,32 @@ export interface PathDefinition<TData extends PathData = PathData> {
195
214
 
196
215
  export type StepStatus = "completed" | "current" | "upcoming";
197
216
 
217
+ /**
218
+ * The engine's current operational state. Exposed as `snapshot.status`.
219
+ *
220
+ * | Status | Meaning |
221
+ * |---------------|--------------------------------------------------------------|
222
+ * | `idle` | On a step, waiting for user input |
223
+ * | `entering` | `onEnter` hook is running |
224
+ * | `validating` | `canMoveNext` / `canMovePrevious` guard is running |
225
+ * | `leaving` | `onLeave` hook is running |
226
+ * | `completing` | `PathDefinition.onComplete` is running |
227
+ * | `error` | An async operation failed — see `snapshot.error` for details |
228
+ */
229
+ export type PathStatus =
230
+ | "idle"
231
+ | "entering"
232
+ | "validating"
233
+ | "leaving"
234
+ | "completing"
235
+ | "error";
236
+
237
+ /**
238
+ * The subset of `PathStatus` values that identify which phase was active
239
+ * when an error occurred. Used by `snapshot.error.phase`.
240
+ */
241
+ export type ErrorPhase = Exclude<PathStatus, "idle" | "error">;
242
+
198
243
  export interface StepSummary {
199
244
  id: string;
200
245
  title?: string;
@@ -253,8 +298,32 @@ export interface PathSnapshot<TData extends PathData = PathData> {
253
298
  * progress bar above the sub-path's own progress bar.
254
299
  */
255
300
  rootProgress?: RootProgress;
256
- /** True while an async guard or hook is executing. Use to disable navigation controls. */
257
- isNavigating: boolean;
301
+ /**
302
+ * The engine's current operational state. Use this instead of multiple boolean flags.
303
+ *
304
+ * Common patterns:
305
+ * - `status !== "idle"` — disable all navigation buttons (engine is busy or errored)
306
+ * - `status === "entering"` — show a skeleton/spinner inside the step area
307
+ * - `status === "validating"` — show "Checking…" on the Next button
308
+ * - `status === "error"` — show the retry / suspend error UI
309
+ */
310
+ status: PathStatus;
311
+ /**
312
+ * Structured error set when an engine-invoked async operation throws. `null` when no error is active.
313
+ *
314
+ * `phase` identifies which operation failed so shells can adapt the message.
315
+ * `retryCount` counts how many times the user has explicitly called `retry()` — starts at 0 on
316
+ * first failure, increments on each retry. Use this to escalate from "Try again" to "Come back later".
317
+ *
318
+ * Cleared automatically when navigation succeeds or when `retry()` is called.
319
+ */
320
+ error: { message: string; phase: ErrorPhase; retryCount: number } | null;
321
+ /**
322
+ * `true` when a `PathStore` is attached via `PathEngineOptions.hasPersistence`.
323
+ * Shells use this to decide whether to promise the user that progress is saved
324
+ * when showing the "come back later" escalation message.
325
+ */
326
+ hasPersistence: boolean;
258
327
  /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldErrors` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
259
328
  canMoveNext: boolean;
260
329
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
@@ -306,6 +375,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
306
375
  * navigating back to a previously visited step).
307
376
  */
308
377
  stepEnteredAt: number;
378
+ /**
379
+ * A guard-level blocking message set when `canMoveNext` returns
380
+ * `{ allowed: false, reason: "..." }`. `null` when there is no blocking message.
381
+ *
382
+ * Distinct from `fieldErrors` (field-attached) and `error` (async crash).
383
+ * Shells render this between the step content and the navigation buttons.
384
+ * Cleared automatically when the user successfully navigates to a new step.
385
+ */
386
+ blockingError: string | null;
309
387
  /**
310
388
  * Field-keyed validation messages for the current step. Empty object when there are none.
311
389
  * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
@@ -335,12 +413,15 @@ export type StateChangeCause =
335
413
  | "setData"
336
414
  | "resetStep"
337
415
  | "cancel"
338
- | "restart";
416
+ | "restart"
417
+ | "retry"
418
+ | "suspend";
339
419
 
340
420
  export type PathEvent =
341
421
  | { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
342
422
  | { type: "completed"; pathId: string; data: PathData }
343
423
  | { type: "cancelled"; pathId: string; data: PathData }
424
+ | { type: "suspended"; pathId: string; data: PathData }
344
425
  | {
345
426
  type: "resumed";
346
427
  resumedPathId: string;
@@ -396,14 +477,15 @@ export type ObserverStrategy =
396
477
  export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean {
397
478
  switch (strategy) {
398
479
  case "onEveryChange":
399
- // Only react once navigation has settled — stateChanged fires twice per
400
- // navigation (isNavigating:true then false).
401
- return (event.type === "stateChanged" && !event.snapshot.isNavigating)
480
+ // Only react once the engine has settled — stateChanged fires on every
481
+ // phase transition; only "idle" and "error" are settled states.
482
+ return (event.type === "stateChanged" &&
483
+ (event.snapshot.status === "idle" || event.snapshot.status === "error"))
402
484
  || event.type === "resumed";
403
485
  case "onNext":
404
486
  return event.type === "stateChanged"
405
487
  && event.cause === "next"
406
- && !event.snapshot.isNavigating;
488
+ && (event.snapshot.status === "idle" || event.snapshot.status === "error");
407
489
  case "onSubPathComplete":
408
490
  return event.type === "resumed";
409
491
  case "onComplete":
@@ -424,6 +506,12 @@ export interface PathEngineOptions {
424
506
  * listeners use `engine.subscribe()`.
425
507
  */
426
508
  observers?: PathObserver[];
509
+ /**
510
+ * Set to `true` when a `PathStore` is attached and will persist path state.
511
+ * Exposed as `snapshot.hasPersistence` so shells can honestly tell the user
512
+ * their progress is saved when showing the "come back later" escalation message.
513
+ */
514
+ hasPersistence?: boolean;
427
515
  }
428
516
 
429
517
  interface ActivePath {
@@ -438,19 +526,61 @@ interface ActivePath {
438
526
  stepEnteredAt: number;
439
527
  /** The selected inner step when the current slot is a StepChoice. Cached on entry. */
440
528
  resolvedChoiceStep?: PathStep;
529
+ /** Step IDs that have been confirmed skipped via shouldSkip during navigation. Used for accurate stepCount / progress in snapshots. */
530
+ resolvedSkips: Set<string>;
441
531
  }
442
532
 
443
533
  function isStepChoice(item: PathStep | StepChoice): item is StepChoice {
444
534
  return "select" in item && "steps" in item;
445
535
  }
446
536
 
537
+ /**
538
+ * Converts a camelCase or lowercase field key to a display label.
539
+ * `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
540
+ * Used by shells to render labeled field-error summaries.
541
+ */
542
+ export function formatFieldKey(key: string): string {
543
+ return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
544
+ }
545
+
546
+ /**
547
+ * Returns a human-readable description of which operation failed, keyed by
548
+ * the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
549
+ * the error panel message.
550
+ */
551
+ export function errorPhaseMessage(phase: string): string {
552
+ switch (phase) {
553
+ case "entering": return "Failed to load this step.";
554
+ case "validating": return "The check could not be completed.";
555
+ case "leaving": return "Failed to save your progress.";
556
+ case "completing": return "Your submission could not be sent.";
557
+ default: return "An unexpected error occurred.";
558
+ }
559
+ }
560
+
447
561
  export class PathEngine {
448
562
  private activePath: ActivePath | null = null;
449
563
  private readonly pathStack: ActivePath[] = [];
450
564
  private readonly listeners = new Set<(event: PathEvent) => void>();
451
- private _isNavigating = false;
565
+ private _status: PathStatus = "idle";
452
566
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
453
567
  private _hasAttemptedNext = false;
568
+ /** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
569
+ private _blockingError: string | null = null;
570
+ /** The path and initial data from the most recent top-level start() call. Used by restart(). */
571
+ private _rootPath: PathDefinition<any> | null = null;
572
+ private _rootInitialData: PathData = {};
573
+ /** Structured error from the most recent failed async operation. Null when no error is active. */
574
+ private _error: { message: string; phase: ErrorPhase; retryCount: number } | null = null;
575
+ /** Stored retry function. Null when no error is pending. */
576
+ private _pendingRetry: (() => Promise<void>) | null = null;
577
+ /**
578
+ * Counts how many times `retry()` has been called for the current error sequence.
579
+ * Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
580
+ */
581
+ private _retryCount = 0;
582
+ private _hasPersistence = false;
583
+ private _hasWarnedAsyncShouldSkip = false;
454
584
 
455
585
  constructor(options?: PathEngineOptions) {
456
586
  if (options?.observers) {
@@ -459,6 +589,9 @@ export class PathEngine {
459
589
  this.listeners.add((event) => observer(event, this));
460
590
  }
461
591
  }
592
+ if (options?.hasPersistence) {
593
+ this._hasPersistence = true;
594
+ }
462
595
  }
463
596
 
464
597
  /**
@@ -500,6 +633,7 @@ export class PathEngine {
500
633
  currentStepIndex: stackItem.currentStepIndex,
501
634
  data: { ...stackItem.data },
502
635
  visitedStepIds: new Set(stackItem.visitedStepIds),
636
+ resolvedSkips: new Set(),
503
637
  subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
504
638
  stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
505
639
  stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
@@ -519,6 +653,7 @@ export class PathEngine {
519
653
  currentStepIndex: state.currentStepIndex,
520
654
  data: { ...state.data },
521
655
  visitedStepIds: new Set(state.visitedStepIds),
656
+ resolvedSkips: new Set(),
522
657
  // Active path's subPathMeta is not serialized (it's transient metadata
523
658
  // from the parent when this path was started). On restore, it's undefined.
524
659
  subPathMeta: undefined,
@@ -526,7 +661,7 @@ export class PathEngine {
526
661
  stepEnteredAt: state.stepEnteredAt ?? Date.now()
527
662
  };
528
663
 
529
- engine._isNavigating = state._isNavigating;
664
+ engine._status = state._status ?? "idle";
530
665
 
531
666
  // Re-derive the selected inner step for any StepChoice slots (not serialized —
532
667
  // always recomputed from current data on restore).
@@ -549,27 +684,31 @@ export class PathEngine {
549
684
 
550
685
  public start(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
551
686
  this.assertPathHasSteps(path);
687
+ this._rootPath = path;
688
+ this._rootInitialData = initialData;
552
689
  return this._startAsync(path, initialData);
553
690
  }
554
691
 
555
692
  /**
556
693
  * Tears down any active path (and the entire sub-path stack) without firing
557
- * lifecycle hooks or emitting `cancelled`, then immediately starts the given
558
- * path from scratch.
694
+ * lifecycle hooks or emitting `cancelled`, then immediately restarts the same
695
+ * path with the same initial data that was passed to the original `start()` call.
559
696
  *
560
697
  * Safe to call at any time — whether a path is running, already completed,
561
698
  * or has never been started. Use this to implement a "Start over" button or
562
699
  * to retry a path after completion without remounting the host component.
563
700
  *
564
- * @param path The path definition to (re)start.
565
- * @param initialData Data to seed the fresh path with. Defaults to `{}`.
701
+ * @throws If `restart()` is called before `start()` has ever been called.
566
702
  */
567
- public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
568
- this.assertPathHasSteps(path);
569
- this._isNavigating = false;
703
+ public restart(): Promise<void> {
704
+ if (!this._rootPath) {
705
+ throw new Error("Cannot restart: engine has not been started. Call start() first.");
706
+ }
707
+ this._status = "idle";
708
+ this._blockingError = null;
570
709
  this.activePath = null;
571
710
  this.pathStack.length = 0;
572
- return this._startAsync(path, initialData);
711
+ return this._startAsync(this._rootPath, { ...this._rootInitialData });
573
712
  }
574
713
 
575
714
  /**
@@ -591,6 +730,14 @@ export class PathEngine {
591
730
 
592
731
  public next(): Promise<void> {
593
732
  const active = this.requireActivePath();
733
+ // Reset the retry sequence. If we're recovering from an error the user
734
+ // explicitly clicked Next again — clear the error and reset to idle so
735
+ // _nextAsync's entry guard passes. For any other non-idle status (busy)
736
+ // the guard in _nextAsync will drop this call.
737
+ this._retryCount = 0;
738
+ this._error = null;
739
+ this._pendingRetry = null;
740
+ if (this._status === "error") this._status = "idle";
594
741
  return this._nextAsync(active);
595
742
  }
596
743
 
@@ -599,12 +746,54 @@ export class PathEngine {
599
746
  return this._previousAsync(active);
600
747
  }
601
748
 
749
+ /**
750
+ * Re-runs the operation that caused the most recent `snapshot.error`.
751
+ * Increments `snapshot.error.retryCount` so shells can escalate from
752
+ * "Try again" to "Come back later" after repeated failures.
753
+ *
754
+ * No-op if there is no pending error or if navigation is in progress.
755
+ */
756
+ public retry(): Promise<void> {
757
+ if (!this._pendingRetry || this._status !== "error") return Promise.resolve();
758
+ this._retryCount++;
759
+ const fn = this._pendingRetry;
760
+ this._pendingRetry = null;
761
+ this._error = null;
762
+ this._status = "idle"; // allow the retry fn's entry guard to pass
763
+ return fn();
764
+ }
765
+
766
+ /**
767
+ * Pauses the path with intent to return. Preserves all state and data.
768
+ *
769
+ * - Clears any active error state
770
+ * - Emits a `suspended` event that the application can listen for to dismiss
771
+ * the wizard UI (close a modal, navigate away, etc.)
772
+ * - The engine remains in its current state — call `start()` / `restoreOrStart()`
773
+ * to resume when the user returns
774
+ *
775
+ * Use in the "Come back later" escalation path when `snapshot.error.retryCount`
776
+ * has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
777
+ * the UI; Pathwrite's persistence layer handles saving progress automatically via
778
+ * the configured store and observer strategy.
779
+ */
780
+ public suspend(): Promise<void> {
781
+ const active = this.activePath;
782
+ const pathId = active?.definition.id ?? "";
783
+ const data = active ? { ...active.data } : {};
784
+ this._error = null;
785
+ this._pendingRetry = null;
786
+ this._status = "idle";
787
+ this.emit({ type: "suspended", pathId, data });
788
+ return Promise.resolve();
789
+ }
790
+
602
791
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
603
792
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
604
793
  * API consistency. */
605
794
  public async cancel(): Promise<void> {
606
795
  const active = this.requireActivePath();
607
- if (this._isNavigating) return;
796
+ if (this._status !== "idle") return;
608
797
 
609
798
  const cancelledPathId = active.definition.id;
610
799
  const cancelledData = { ...active.data };
@@ -684,25 +873,34 @@ export class PathEngine {
684
873
  const item = this.getCurrentItem(active);
685
874
  const effectiveStep = this.getEffectiveStep(active);
686
875
  const { steps } = active.definition;
687
- const stepCount = steps.length;
876
+
877
+ // Filter out steps confirmed as skipped during navigation. Steps not yet
878
+ // evaluated (e.g. on first render) are included optimistically.
879
+ const visibleSteps = steps.filter(s => !active.resolvedSkips.has(s.id));
880
+ const stepCount = visibleSteps.length;
881
+ const visibleIndex = visibleSteps.findIndex(s => s.id === item.id);
882
+ // Fall back to raw index if not found (should not happen in normal use)
883
+ const effectiveStepIndex = visibleIndex >= 0 ? visibleIndex : active.currentStepIndex;
688
884
 
689
885
  // Build rootProgress from the bottom of the stack (the top-level path)
690
886
  let rootProgress: RootProgress | undefined;
691
887
  if (this.pathStack.length > 0) {
692
888
  const root = this.pathStack[0];
693
- const rootSteps = root.definition.steps;
694
- const rootStepCount = rootSteps.length;
889
+ const rootVisibleSteps = root.definition.steps.filter(s => !root.resolvedSkips.has(s.id));
890
+ const rootStepCount = rootVisibleSteps.length;
891
+ const rootVisibleIndex = rootVisibleSteps.findIndex(s => s.id === root.definition.steps[root.currentStepIndex]?.id);
892
+ const rootEffectiveIndex = rootVisibleIndex >= 0 ? rootVisibleIndex : root.currentStepIndex;
695
893
  rootProgress = {
696
894
  pathId: root.definition.id,
697
- stepIndex: root.currentStepIndex,
895
+ stepIndex: rootEffectiveIndex,
698
896
  stepCount: rootStepCount,
699
- progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
700
- steps: rootSteps.map((s, i) => ({
897
+ progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
898
+ steps: rootVisibleSteps.map((s, i) => ({
701
899
  id: s.id,
702
900
  title: s.title,
703
901
  meta: s.meta,
704
- status: i < root.currentStepIndex ? "completed" as const
705
- : i === root.currentStepIndex ? "current" as const
902
+ status: i < rootEffectiveIndex ? "completed" as const
903
+ : i === rootEffectiveIndex ? "current" as const
706
904
  : "upcoming" as const
707
905
  }))
708
906
  };
@@ -714,25 +912,28 @@ export class PathEngine {
714
912
  stepTitle: effectiveStep.title ?? item.title,
715
913
  stepMeta: effectiveStep.meta ?? item.meta,
716
914
  formId: isStepChoice(item) ? effectiveStep.id : undefined,
717
- stepIndex: active.currentStepIndex,
915
+ stepIndex: effectiveStepIndex,
718
916
  stepCount,
719
- progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
720
- steps: steps.map((s, i) => ({
917
+ progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
918
+ steps: visibleSteps.map((s, i) => ({
721
919
  id: s.id,
722
920
  title: s.title,
723
921
  meta: s.meta,
724
- status: i < active.currentStepIndex ? "completed" as const
725
- : i === active.currentStepIndex ? "current" as const
922
+ status: i < effectiveStepIndex ? "completed" as const
923
+ : i === effectiveStepIndex ? "current" as const
726
924
  : "upcoming" as const
727
925
  })),
728
- isFirstStep: active.currentStepIndex === 0,
926
+ isFirstStep: effectiveStepIndex === 0,
729
927
  isLastStep:
730
- active.currentStepIndex === stepCount - 1 &&
928
+ effectiveStepIndex === stepCount - 1 &&
731
929
  this.pathStack.length === 0,
732
930
  nestingLevel: this.pathStack.length,
733
931
  rootProgress,
734
- isNavigating: this._isNavigating,
932
+ status: this._status,
933
+ error: this._error,
934
+ hasPersistence: this._hasPersistence,
735
935
  hasAttemptedNext: this._hasAttemptedNext,
936
+ blockingError: this._blockingError,
736
937
  canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
737
938
  canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
738
939
  fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
@@ -745,7 +946,7 @@ export class PathEngine {
745
946
 
746
947
  /**
747
948
  * Exports the current engine state as a plain JSON-serializable object.
748
- * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
949
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
749
950
  * persist and restore wizard progress.
750
951
  *
751
952
  * Returns `null` if no path is active.
@@ -778,7 +979,7 @@ export class PathEngine {
778
979
  stepEntryData: { ...p.stepEntryData },
779
980
  stepEnteredAt: p.stepEnteredAt
780
981
  })),
781
- _isNavigating: this._isNavigating
982
+ _status: this._status
782
983
  };
783
984
  }
784
985
 
@@ -787,7 +988,7 @@ export class PathEngine {
787
988
  // ---------------------------------------------------------------------------
788
989
 
789
990
  private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
790
- if (this._isNavigating) return;
991
+ if (this._status !== "idle") return;
791
992
 
792
993
  if (this.activePath !== null) {
793
994
  // Store the meta on the parent before pushing to stack
@@ -803,90 +1004,104 @@ export class PathEngine {
803
1004
  currentStepIndex: 0,
804
1005
  data: { ...initialData },
805
1006
  visitedStepIds: new Set(),
1007
+ resolvedSkips: new Set(),
806
1008
  subPathMeta: undefined,
807
1009
  stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
808
1010
  stepEnteredAt: 0 // Will be set in enterCurrentStep
809
1011
  };
810
1012
 
811
- this._isNavigating = true;
812
-
813
1013
  await this.skipSteps(1);
814
1014
 
815
1015
  if (this.activePath.currentStepIndex >= path.steps.length) {
816
- this._isNavigating = false;
817
- await this.finishActivePath();
1016
+ await this._finishActivePathWithErrorHandling();
818
1017
  return;
819
1018
  }
820
1019
 
1020
+ this._status = "entering";
821
1021
  this.emitStateChanged("start");
822
1022
 
823
- try {
824
- this.applyPatch(await this.enterCurrentStep());
825
- this._isNavigating = false;
826
- this.emitStateChanged("start");
827
- } catch (err) {
828
- this._isNavigating = false;
829
- this.emitStateChanged("start");
830
- throw err;
831
- }
1023
+ await this._enterCurrentStepWithErrorHandling("start");
832
1024
  }
833
1025
 
834
1026
  private async _nextAsync(active: ActivePath): Promise<void> {
835
- if (this._isNavigating) return;
1027
+ if (this._status !== "idle") return;
836
1028
 
837
1029
  // Record that the user has attempted to advance — used by shells and step
838
1030
  // templates to gate error display ("punish late, reward early").
839
1031
  this._hasAttemptedNext = true;
840
- this._isNavigating = true;
1032
+
1033
+ // Phase: validating — canMoveNext guard
1034
+ this._status = "validating";
841
1035
  this.emitStateChanged("next");
842
1036
 
1037
+ let guardResult: { allowed: boolean; reason: string | null };
843
1038
  try {
844
- const step = this.getEffectiveStep(active);
1039
+ guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
1040
+ } catch (err) {
1041
+ this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
1042
+ this._pendingRetry = () => this._nextAsync(active);
1043
+ this._status = "error";
1044
+ this.emitStateChanged("next");
1045
+ return;
1046
+ }
845
1047
 
846
- if (await this.canMoveNext(active, step)) {
847
- this.applyPatch(await this.leaveCurrentStep(active, step));
848
- active.currentStepIndex += 1;
849
- await this.skipSteps(1);
1048
+ if (guardResult.allowed) {
1049
+ // Phase: leaving — onLeave hook
1050
+ this._status = "leaving";
1051
+ this.emitStateChanged("next");
1052
+ try {
1053
+ this.applyPatch(await this.leaveCurrentStep(active, this.getEffectiveStep(active)));
1054
+ } catch (err) {
1055
+ this._error = { message: PathEngine.errorMessage(err), phase: "leaving", retryCount: this._retryCount };
1056
+ this._pendingRetry = () => this._nextAsync(active);
1057
+ this._status = "error";
1058
+ this.emitStateChanged("next");
1059
+ return;
1060
+ }
850
1061
 
851
- if (active.currentStepIndex >= active.definition.steps.length) {
852
- this._isNavigating = false;
853
- await this.finishActivePath();
854
- return;
855
- }
1062
+ active.currentStepIndex += 1;
1063
+ await this.skipSteps(1);
856
1064
 
857
- this.applyPatch(await this.enterCurrentStep());
1065
+ if (active.currentStepIndex >= active.definition.steps.length) {
1066
+ // Phase: completing — PathDefinition.onComplete
1067
+ await this._finishActivePathWithErrorHandling();
1068
+ return;
858
1069
  }
859
1070
 
860
- this._isNavigating = false;
861
- this.emitStateChanged("next");
862
- } catch (err) {
863
- this._isNavigating = false;
864
- this.emitStateChanged("next");
865
- throw err;
1071
+ // Phase: entering — onEnter hook on the new step
1072
+ await this._enterCurrentStepWithErrorHandling("next");
1073
+ return;
866
1074
  }
1075
+
1076
+ this._blockingError = guardResult.reason;
1077
+ this._status = "idle";
1078
+ this.emitStateChanged("next");
867
1079
  }
868
1080
 
869
1081
  private async _previousAsync(active: ActivePath): Promise<void> {
870
- if (this._isNavigating) return;
1082
+ if (this._status !== "idle") return;
871
1083
 
872
1084
  // No-op when already on the first step of a top-level path.
873
1085
  // Sub-paths still cancel/pop back to the parent when previous() is called
874
1086
  // on their first step (the currentStepIndex < 0 branch below handles that).
875
1087
  if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
876
1088
 
877
- this._isNavigating = true;
1089
+ this._status = "leaving";
878
1090
  this.emitStateChanged("previous");
879
1091
 
880
1092
  try {
881
1093
  const step = this.getEffectiveStep(active);
882
1094
 
883
- if (await this.canMovePrevious(active, step)) {
1095
+ const prevGuard = await this.canMovePrevious(active, step);
1096
+ if (!prevGuard.allowed) this._blockingError = prevGuard.reason;
1097
+
1098
+ if (prevGuard.allowed) {
884
1099
  this.applyPatch(await this.leaveCurrentStep(active, step));
885
1100
  active.currentStepIndex -= 1;
886
1101
  await this.skipSteps(-1);
887
1102
 
888
1103
  if (active.currentStepIndex < 0) {
889
- this._isNavigating = false;
1104
+ this._status = "idle";
890
1105
  await this.cancel();
891
1106
  return;
892
1107
  }
@@ -894,19 +1109,19 @@ export class PathEngine {
894
1109
  this.applyPatch(await this.enterCurrentStep());
895
1110
  }
896
1111
 
897
- this._isNavigating = false;
1112
+ this._status = "idle";
898
1113
  this.emitStateChanged("previous");
899
1114
  } catch (err) {
900
- this._isNavigating = false;
1115
+ this._status = "idle";
901
1116
  this.emitStateChanged("previous");
902
1117
  throw err;
903
1118
  }
904
1119
  }
905
1120
 
906
1121
  private async _goToStepAsync(active: ActivePath, targetIndex: number): Promise<void> {
907
- if (this._isNavigating) return;
1122
+ if (this._status !== "idle") return;
908
1123
 
909
- this._isNavigating = true;
1124
+ this._status = "leaving";
910
1125
  this.emitStateChanged("goToStep");
911
1126
 
912
1127
  try {
@@ -916,38 +1131,41 @@ export class PathEngine {
916
1131
  active.currentStepIndex = targetIndex;
917
1132
 
918
1133
  this.applyPatch(await this.enterCurrentStep());
919
- this._isNavigating = false;
1134
+ this._status = "idle";
920
1135
  this.emitStateChanged("goToStep");
921
1136
  } catch (err) {
922
- this._isNavigating = false;
1137
+ this._status = "idle";
923
1138
  this.emitStateChanged("goToStep");
924
1139
  throw err;
925
1140
  }
926
1141
  }
927
1142
 
928
1143
  private async _goToStepCheckedAsync(active: ActivePath, targetIndex: number): Promise<void> {
929
- if (this._isNavigating) return;
1144
+ if (this._status !== "idle") return;
930
1145
 
931
- this._isNavigating = true;
1146
+ this._status = "validating";
932
1147
  this.emitStateChanged("goToStepChecked");
933
1148
 
934
1149
  try {
935
1150
  const currentStep = this.getEffectiveStep(active);
936
1151
  const goingForward = targetIndex > active.currentStepIndex;
937
- const allowed = goingForward
1152
+ const guardResult = goingForward
938
1153
  ? await this.canMoveNext(active, currentStep)
939
1154
  : await this.canMovePrevious(active, currentStep);
940
1155
 
941
- if (allowed) {
1156
+ if (!guardResult.allowed) this._blockingError = guardResult.reason;
1157
+
1158
+ if (guardResult.allowed) {
1159
+ this._status = "leaving";
942
1160
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
943
1161
  active.currentStepIndex = targetIndex;
944
1162
  this.applyPatch(await this.enterCurrentStep());
945
1163
  }
946
1164
 
947
- this._isNavigating = false;
1165
+ this._status = "idle";
948
1166
  this.emitStateChanged("goToStepChecked");
949
1167
  } catch (err) {
950
- this._isNavigating = false;
1168
+ this._status = "idle";
951
1169
  this.emitStateChanged("goToStepChecked");
952
1170
  throw err;
953
1171
  }
@@ -963,7 +1181,7 @@ export class PathEngine {
963
1181
  // sub-path (which may have currentStepIndex = -1).
964
1182
  this.activePath = this.pathStack.pop() ?? null;
965
1183
 
966
- this._isNavigating = true;
1184
+ this._status = "leaving";
967
1185
  this.emitStateChanged("cancel");
968
1186
 
969
1187
  try {
@@ -985,10 +1203,10 @@ export class PathEngine {
985
1203
  }
986
1204
  }
987
1205
 
988
- this._isNavigating = false;
1206
+ this._status = "idle";
989
1207
  this.emitStateChanged("cancel");
990
1208
  } catch (err) {
991
- this._isNavigating = false;
1209
+ this._status = "idle";
992
1210
  this.emitStateChanged("cancel");
993
1211
  throw err;
994
1212
  }
@@ -1026,12 +1244,66 @@ export class PathEngine {
1026
1244
  snapshot: this.snapshot()!
1027
1245
  });
1028
1246
  } else {
1029
- // Top-level path completed — call onComplete hook if defined
1030
- this.activePath = null;
1031
- this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1247
+ // Top-level path completed — call onComplete before clearing activePath so
1248
+ // that if it throws the engine remains on the final step and can retry.
1032
1249
  if (finished.definition.onComplete) {
1033
1250
  await finished.definition.onComplete(finishedData);
1034
1251
  }
1252
+ this.activePath = null;
1253
+ this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Wraps `finishActivePath` with error handling for the `completing` phase.
1259
+ * On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
1260
+ * resets status to `"error"`, and emits `stateChanged`.
1261
+ * On success: resets status to `"idle"` (finishActivePath sets activePath = null,
1262
+ * so no stateChanged is needed — the `completed` event is the terminal signal).
1263
+ */
1264
+ private async _finishActivePathWithErrorHandling(): Promise<void> {
1265
+ const active = this.activePath;
1266
+ this._status = "completing";
1267
+ try {
1268
+ await this.finishActivePath();
1269
+ this._status = "idle";
1270
+ // No stateChanged here — finishActivePath emits "completed" or "resumed"
1271
+ } catch (err) {
1272
+ this._error = { message: PathEngine.errorMessage(err), phase: "completing", retryCount: this._retryCount };
1273
+ // Retry: call finishActivePath again (activePath is still set because onComplete
1274
+ // throws before this.activePath = null in the restructured finishActivePath)
1275
+ this._pendingRetry = () => this._finishActivePathWithErrorHandling();
1276
+ this._status = "error";
1277
+ if (active) {
1278
+ // Restore activePath if it was cleared mid-throw (defensive)
1279
+ if (!this.activePath) this.activePath = active;
1280
+ // Back up to the last valid step so snapshot() can render it while error is shown
1281
+ if (this.activePath.currentStepIndex >= this.activePath.definition.steps.length) {
1282
+ this.activePath.currentStepIndex = this.activePath.definition.steps.length - 1;
1283
+ }
1284
+ this.emitStateChanged("next");
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Wraps `enterCurrentStep` with error handling for the `entering` phase.
1291
+ * Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
1292
+ * On failure: sets `_error`, stores a retry that re-calls this method,
1293
+ * resets status to `"error"`, and emits `stateChanged` with the given `cause`.
1294
+ */
1295
+ private async _enterCurrentStepWithErrorHandling(cause: StateChangeCause): Promise<void> {
1296
+ this._status = "entering";
1297
+ try {
1298
+ this.applyPatch(await this.enterCurrentStep());
1299
+ this._status = "idle";
1300
+ this.emitStateChanged(cause);
1301
+ } catch (err) {
1302
+ this._error = { message: PathEngine.errorMessage(err), phase: "entering", retryCount: this._retryCount };
1303
+ // Retry: re-enter the current step (don't repeat guards/leave)
1304
+ this._pendingRetry = () => this._enterCurrentStepWithErrorHandling(cause);
1305
+ this._status = "error";
1306
+ this.emitStateChanged(cause);
1035
1307
  }
1036
1308
  }
1037
1309
 
@@ -1039,6 +1311,10 @@ export class PathEngine {
1039
1311
  // Private helpers
1040
1312
  // ---------------------------------------------------------------------------
1041
1313
 
1314
+ private static errorMessage(err: unknown): string {
1315
+ return err instanceof Error ? err.message : String(err);
1316
+ }
1317
+
1042
1318
  private requireActivePath(): ActivePath {
1043
1319
  if (this.activePath === null) {
1044
1320
  throw new Error("No active path.");
@@ -1137,15 +1413,36 @@ export class PathEngine {
1137
1413
  active.currentStepIndex < active.definition.steps.length
1138
1414
  ) {
1139
1415
  const item = active.definition.steps[active.currentStepIndex];
1140
- if (!item.shouldSkip) break;
1416
+ if (!item.shouldSkip) {
1417
+ // This step has no shouldSkip — it is definitely visible. Remove it from
1418
+ // the cache in case a previous navigation had marked it as skipped.
1419
+ active.resolvedSkips.delete(item.id);
1420
+ break;
1421
+ }
1141
1422
  const ctx: PathStepContext = {
1142
1423
  pathId: active.definition.id,
1143
1424
  stepId: item.id,
1144
1425
  data: { ...active.data },
1145
1426
  isFirstEntry: !active.visitedStepIds.has(item.id)
1146
1427
  };
1147
- const skip = await item.shouldSkip(ctx);
1148
- if (!skip) break;
1428
+ const rawResult = item.shouldSkip(ctx);
1429
+ if (rawResult && typeof (rawResult as Promise<boolean>).then === "function") {
1430
+ if (!this._hasWarnedAsyncShouldSkip) {
1431
+ this._hasWarnedAsyncShouldSkip = true;
1432
+ console.warn(
1433
+ `[Pathwrite] Step "${item.id}" has an async shouldSkip. ` +
1434
+ `snapshot().stepCount and progress may be approximate until after the first navigation.`
1435
+ );
1436
+ }
1437
+ }
1438
+ const skip = await rawResult;
1439
+ if (!skip) {
1440
+ // This step resolved as NOT skipped — remove from cache in case it was
1441
+ // previously skipped (data changed since last navigation).
1442
+ active.resolvedSkips.delete(item.id);
1443
+ break;
1444
+ }
1445
+ active.resolvedSkips.add(item.id);
1149
1446
  active.currentStepIndex += direction;
1150
1447
  }
1151
1448
  }
@@ -1153,6 +1450,7 @@ export class PathEngine {
1153
1450
  private async enterCurrentStep(): Promise<Partial<PathData> | void> {
1154
1451
  // Each step starts fresh — errors are not shown until the user attempts to proceed.
1155
1452
  this._hasAttemptedNext = false;
1453
+ this._blockingError = null;
1156
1454
  const active = this.activePath;
1157
1455
  if (!active) return;
1158
1456
 
@@ -1199,7 +1497,7 @@ export class PathEngine {
1199
1497
  private async canMoveNext(
1200
1498
  active: ActivePath,
1201
1499
  step: PathStep
1202
- ): Promise<boolean> {
1500
+ ): Promise<{ allowed: boolean; reason: string | null }> {
1203
1501
  if (step.canMoveNext) {
1204
1502
  const ctx: PathStepContext = {
1205
1503
  pathId: active.definition.id,
@@ -1207,26 +1505,34 @@ export class PathEngine {
1207
1505
  data: { ...active.data },
1208
1506
  isFirstEntry: !active.visitedStepIds.has(step.id)
1209
1507
  };
1210
- return step.canMoveNext(ctx);
1508
+ const result = await step.canMoveNext(ctx);
1509
+ return PathEngine.normaliseGuardResult(result);
1211
1510
  }
1212
1511
  if (step.fieldErrors) {
1213
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1512
+ const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1513
+ return { allowed, reason: null };
1214
1514
  }
1215
- return true;
1515
+ return { allowed: true, reason: null };
1216
1516
  }
1217
1517
 
1218
1518
  private async canMovePrevious(
1219
1519
  active: ActivePath,
1220
1520
  step: PathStep
1221
- ): Promise<boolean> {
1222
- if (!step.canMovePrevious) return true;
1521
+ ): Promise<{ allowed: boolean; reason: string | null }> {
1522
+ if (!step.canMovePrevious) return { allowed: true, reason: null };
1223
1523
  const ctx: PathStepContext = {
1224
1524
  pathId: active.definition.id,
1225
1525
  stepId: step.id,
1226
1526
  data: { ...active.data },
1227
1527
  isFirstEntry: !active.visitedStepIds.has(step.id)
1228
1528
  };
1229
- return step.canMovePrevious(ctx);
1529
+ const result = await step.canMovePrevious(ctx);
1530
+ return PathEngine.normaliseGuardResult(result);
1531
+ }
1532
+
1533
+ private static normaliseGuardResult(result: GuardResult): { allowed: boolean; reason: string | null } {
1534
+ if (result === true) return { allowed: true, reason: null };
1535
+ return { allowed: false, reason: result.reason ?? null };
1230
1536
  }
1231
1537
 
1232
1538
  /**
@@ -1244,7 +1550,7 @@ export class PathEngine {
1244
1550
  * safe default (`true`) is returned so the UI remains operable.
1245
1551
  */
1246
1552
  private evaluateGuardSync(
1247
- guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
1553
+ guard: ((ctx: PathStepContext) => GuardResult | Promise<GuardResult>) | undefined,
1248
1554
  active: ActivePath
1249
1555
  ): boolean {
1250
1556
  if (!guard) return true;
@@ -1257,17 +1563,20 @@ export class PathEngine {
1257
1563
  };
1258
1564
  try {
1259
1565
  const result = guard(ctx);
1260
- if (typeof result === "boolean") return result;
1261
- // Async guard detected - warn and return optimistic default
1262
- if (result && typeof result.then === "function") {
1566
+ if (result === true) return true;
1567
+ if (result && typeof (result as Promise<unknown>).then === "function") {
1568
+ // Async guard detected - suppress the unhandled rejection, warn, return optimistic default
1569
+ (result as Promise<unknown>).catch(() => {});
1263
1570
  console.warn(
1264
1571
  `[pathwrite] Async guard detected on step "${item.id}". ` +
1265
1572
  `Guards in snapshots must be synchronous. ` +
1266
1573
  `Returning true (optimistic) as default. ` +
1267
1574
  `The async guard will still be enforced during actual navigation.`
1268
1575
  );
1576
+ return true;
1269
1577
  }
1270
- return true;
1578
+ // { allowed: false, reason? } object returned synchronously
1579
+ return false;
1271
1580
  } catch (err) {
1272
1581
  console.warn(
1273
1582
  `[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +