@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/dist/index.js CHANGED
@@ -14,14 +14,15 @@
14
14
  export function matchesStrategy(strategy, event) {
15
15
  switch (strategy) {
16
16
  case "onEveryChange":
17
- // Only react once navigation has settled — stateChanged fires twice per
18
- // navigation (isNavigating:true then false).
19
- return (event.type === "stateChanged" && !event.snapshot.isNavigating)
17
+ // Only react once the engine has settled — stateChanged fires on every
18
+ // phase transition; only "idle" and "error" are settled states.
19
+ return (event.type === "stateChanged" &&
20
+ (event.snapshot.status === "idle" || event.snapshot.status === "error"))
20
21
  || event.type === "resumed";
21
22
  case "onNext":
22
23
  return event.type === "stateChanged"
23
24
  && event.cause === "next"
24
- && !event.snapshot.isNavigating;
25
+ && (event.snapshot.status === "idle" || event.snapshot.status === "error");
25
26
  case "onSubPathComplete":
26
27
  return event.type === "resumed";
27
28
  case "onComplete":
@@ -33,13 +34,51 @@ export function matchesStrategy(strategy, event) {
33
34
  function isStepChoice(item) {
34
35
  return "select" in item && "steps" in item;
35
36
  }
37
+ /**
38
+ * Converts a camelCase or lowercase field key to a display label.
39
+ * `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
40
+ * Used by shells to render labeled field-error summaries.
41
+ */
42
+ export function formatFieldKey(key) {
43
+ return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
44
+ }
45
+ /**
46
+ * Returns a human-readable description of which operation failed, keyed by
47
+ * the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
48
+ * the error panel message.
49
+ */
50
+ export function errorPhaseMessage(phase) {
51
+ switch (phase) {
52
+ case "entering": return "Failed to load this step.";
53
+ case "validating": return "The check could not be completed.";
54
+ case "leaving": return "Failed to save your progress.";
55
+ case "completing": return "Your submission could not be sent.";
56
+ default: return "An unexpected error occurred.";
57
+ }
58
+ }
36
59
  export class PathEngine {
37
60
  activePath = null;
38
61
  pathStack = [];
39
62
  listeners = new Set();
40
- _isNavigating = false;
63
+ _status = "idle";
41
64
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
42
65
  _hasAttemptedNext = false;
66
+ /** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
67
+ _blockingError = null;
68
+ /** The path and initial data from the most recent top-level start() call. Used by restart(). */
69
+ _rootPath = null;
70
+ _rootInitialData = {};
71
+ /** Structured error from the most recent failed async operation. Null when no error is active. */
72
+ _error = null;
73
+ /** Stored retry function. Null when no error is pending. */
74
+ _pendingRetry = null;
75
+ /**
76
+ * Counts how many times `retry()` has been called for the current error sequence.
77
+ * Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
78
+ */
79
+ _retryCount = 0;
80
+ _hasPersistence = false;
81
+ _hasWarnedAsyncShouldSkip = false;
43
82
  constructor(options) {
44
83
  if (options?.observers) {
45
84
  for (const observer of options.observers) {
@@ -47,6 +86,9 @@ export class PathEngine {
47
86
  this.listeners.add((event) => observer(event, this));
48
87
  }
49
88
  }
89
+ if (options?.hasPersistence) {
90
+ this._hasPersistence = true;
91
+ }
50
92
  }
51
93
  /**
52
94
  * Restores a PathEngine from previously exported state.
@@ -79,6 +121,7 @@ export class PathEngine {
79
121
  currentStepIndex: stackItem.currentStepIndex,
80
122
  data: { ...stackItem.data },
81
123
  visitedStepIds: new Set(stackItem.visitedStepIds),
124
+ resolvedSkips: new Set(),
82
125
  subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
83
126
  stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
84
127
  stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
@@ -94,13 +137,14 @@ export class PathEngine {
94
137
  currentStepIndex: state.currentStepIndex,
95
138
  data: { ...state.data },
96
139
  visitedStepIds: new Set(state.visitedStepIds),
140
+ resolvedSkips: new Set(),
97
141
  // Active path's subPathMeta is not serialized (it's transient metadata
98
142
  // from the parent when this path was started). On restore, it's undefined.
99
143
  subPathMeta: undefined,
100
144
  stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
101
145
  stepEnteredAt: state.stepEnteredAt ?? Date.now()
102
146
  };
103
- engine._isNavigating = state._isNavigating;
147
+ engine._status = state._status ?? "idle";
104
148
  // Re-derive the selected inner step for any StepChoice slots (not serialized —
105
149
  // always recomputed from current data on restore).
106
150
  for (const stackItem of engine.pathStack) {
@@ -118,26 +162,30 @@ export class PathEngine {
118
162
  // ---------------------------------------------------------------------------
119
163
  start(path, initialData = {}) {
120
164
  this.assertPathHasSteps(path);
165
+ this._rootPath = path;
166
+ this._rootInitialData = initialData;
121
167
  return this._startAsync(path, initialData);
122
168
  }
123
169
  /**
124
170
  * Tears down any active path (and the entire sub-path stack) without firing
125
- * lifecycle hooks or emitting `cancelled`, then immediately starts the given
126
- * path from scratch.
171
+ * lifecycle hooks or emitting `cancelled`, then immediately restarts the same
172
+ * path with the same initial data that was passed to the original `start()` call.
127
173
  *
128
174
  * Safe to call at any time — whether a path is running, already completed,
129
175
  * or has never been started. Use this to implement a "Start over" button or
130
176
  * to retry a path after completion without remounting the host component.
131
177
  *
132
- * @param path The path definition to (re)start.
133
- * @param initialData Data to seed the fresh path with. Defaults to `{}`.
178
+ * @throws If `restart()` is called before `start()` has ever been called.
134
179
  */
135
- restart(path, initialData = {}) {
136
- this.assertPathHasSteps(path);
137
- this._isNavigating = false;
180
+ restart() {
181
+ if (!this._rootPath) {
182
+ throw new Error("Cannot restart: engine has not been started. Call start() first.");
183
+ }
184
+ this._status = "idle";
185
+ this._blockingError = null;
138
186
  this.activePath = null;
139
187
  this.pathStack.length = 0;
140
- return this._startAsync(path, initialData);
188
+ return this._startAsync(this._rootPath, { ...this._rootInitialData });
141
189
  }
142
190
  /**
143
191
  * Starts a sub-path on top of the currently active path. Throws if no path
@@ -157,18 +205,68 @@ export class PathEngine {
157
205
  }
158
206
  next() {
159
207
  const active = this.requireActivePath();
208
+ // Reset the retry sequence. If we're recovering from an error the user
209
+ // explicitly clicked Next again — clear the error and reset to idle so
210
+ // _nextAsync's entry guard passes. For any other non-idle status (busy)
211
+ // the guard in _nextAsync will drop this call.
212
+ this._retryCount = 0;
213
+ this._error = null;
214
+ this._pendingRetry = null;
215
+ if (this._status === "error")
216
+ this._status = "idle";
160
217
  return this._nextAsync(active);
161
218
  }
162
219
  previous() {
163
220
  const active = this.requireActivePath();
164
221
  return this._previousAsync(active);
165
222
  }
223
+ /**
224
+ * Re-runs the operation that caused the most recent `snapshot.error`.
225
+ * Increments `snapshot.error.retryCount` so shells can escalate from
226
+ * "Try again" to "Come back later" after repeated failures.
227
+ *
228
+ * No-op if there is no pending error or if navigation is in progress.
229
+ */
230
+ retry() {
231
+ if (!this._pendingRetry || this._status !== "error")
232
+ return Promise.resolve();
233
+ this._retryCount++;
234
+ const fn = this._pendingRetry;
235
+ this._pendingRetry = null;
236
+ this._error = null;
237
+ this._status = "idle"; // allow the retry fn's entry guard to pass
238
+ return fn();
239
+ }
240
+ /**
241
+ * Pauses the path with intent to return. Preserves all state and data.
242
+ *
243
+ * - Clears any active error state
244
+ * - Emits a `suspended` event that the application can listen for to dismiss
245
+ * the wizard UI (close a modal, navigate away, etc.)
246
+ * - The engine remains in its current state — call `start()` / `restoreOrStart()`
247
+ * to resume when the user returns
248
+ *
249
+ * Use in the "Come back later" escalation path when `snapshot.error.retryCount`
250
+ * has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
251
+ * the UI; Pathwrite's persistence layer handles saving progress automatically via
252
+ * the configured store and observer strategy.
253
+ */
254
+ suspend() {
255
+ const active = this.activePath;
256
+ const pathId = active?.definition.id ?? "";
257
+ const data = active ? { ...active.data } : {};
258
+ this._error = null;
259
+ this._pendingRetry = null;
260
+ this._status = "idle";
261
+ this.emit({ type: "suspended", pathId, data });
262
+ return Promise.resolve();
263
+ }
166
264
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
167
265
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
168
266
  * API consistency. */
169
267
  async cancel() {
170
268
  const active = this.requireActivePath();
171
- if (this._isNavigating)
269
+ if (this._status !== "idle")
172
270
  return;
173
271
  const cancelledPathId = active.definition.id;
174
272
  const cancelledData = { ...active.data };
@@ -241,24 +339,32 @@ export class PathEngine {
241
339
  const item = this.getCurrentItem(active);
242
340
  const effectiveStep = this.getEffectiveStep(active);
243
341
  const { steps } = active.definition;
244
- const stepCount = steps.length;
342
+ // Filter out steps confirmed as skipped during navigation. Steps not yet
343
+ // evaluated (e.g. on first render) are included optimistically.
344
+ const visibleSteps = steps.filter(s => !active.resolvedSkips.has(s.id));
345
+ const stepCount = visibleSteps.length;
346
+ const visibleIndex = visibleSteps.findIndex(s => s.id === item.id);
347
+ // Fall back to raw index if not found (should not happen in normal use)
348
+ const effectiveStepIndex = visibleIndex >= 0 ? visibleIndex : active.currentStepIndex;
245
349
  // Build rootProgress from the bottom of the stack (the top-level path)
246
350
  let rootProgress;
247
351
  if (this.pathStack.length > 0) {
248
352
  const root = this.pathStack[0];
249
- const rootSteps = root.definition.steps;
250
- const rootStepCount = rootSteps.length;
353
+ const rootVisibleSteps = root.definition.steps.filter(s => !root.resolvedSkips.has(s.id));
354
+ const rootStepCount = rootVisibleSteps.length;
355
+ const rootVisibleIndex = rootVisibleSteps.findIndex(s => s.id === root.definition.steps[root.currentStepIndex]?.id);
356
+ const rootEffectiveIndex = rootVisibleIndex >= 0 ? rootVisibleIndex : root.currentStepIndex;
251
357
  rootProgress = {
252
358
  pathId: root.definition.id,
253
- stepIndex: root.currentStepIndex,
359
+ stepIndex: rootEffectiveIndex,
254
360
  stepCount: rootStepCount,
255
- progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
256
- steps: rootSteps.map((s, i) => ({
361
+ progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
362
+ steps: rootVisibleSteps.map((s, i) => ({
257
363
  id: s.id,
258
364
  title: s.title,
259
365
  meta: s.meta,
260
- status: i < root.currentStepIndex ? "completed"
261
- : i === root.currentStepIndex ? "current"
366
+ status: i < rootEffectiveIndex ? "completed"
367
+ : i === rootEffectiveIndex ? "current"
262
368
  : "upcoming"
263
369
  }))
264
370
  };
@@ -269,24 +375,27 @@ export class PathEngine {
269
375
  stepTitle: effectiveStep.title ?? item.title,
270
376
  stepMeta: effectiveStep.meta ?? item.meta,
271
377
  formId: isStepChoice(item) ? effectiveStep.id : undefined,
272
- stepIndex: active.currentStepIndex,
378
+ stepIndex: effectiveStepIndex,
273
379
  stepCount,
274
- progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
275
- steps: steps.map((s, i) => ({
380
+ progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
381
+ steps: visibleSteps.map((s, i) => ({
276
382
  id: s.id,
277
383
  title: s.title,
278
384
  meta: s.meta,
279
- status: i < active.currentStepIndex ? "completed"
280
- : i === active.currentStepIndex ? "current"
385
+ status: i < effectiveStepIndex ? "completed"
386
+ : i === effectiveStepIndex ? "current"
281
387
  : "upcoming"
282
388
  })),
283
- isFirstStep: active.currentStepIndex === 0,
284
- isLastStep: active.currentStepIndex === stepCount - 1 &&
389
+ isFirstStep: effectiveStepIndex === 0,
390
+ isLastStep: effectiveStepIndex === stepCount - 1 &&
285
391
  this.pathStack.length === 0,
286
392
  nestingLevel: this.pathStack.length,
287
393
  rootProgress,
288
- isNavigating: this._isNavigating,
394
+ status: this._status,
395
+ error: this._error,
396
+ hasPersistence: this._hasPersistence,
289
397
  hasAttemptedNext: this._hasAttemptedNext,
398
+ blockingError: this._blockingError,
290
399
  canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
291
400
  canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
292
401
  fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
@@ -298,7 +407,7 @@ export class PathEngine {
298
407
  }
299
408
  /**
300
409
  * Exports the current engine state as a plain JSON-serializable object.
301
- * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
410
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
302
411
  * persist and restore wizard progress.
303
412
  *
304
413
  * Returns `null` if no path is active.
@@ -329,14 +438,14 @@ export class PathEngine {
329
438
  stepEntryData: { ...p.stepEntryData },
330
439
  stepEnteredAt: p.stepEnteredAt
331
440
  })),
332
- _isNavigating: this._isNavigating
441
+ _status: this._status
333
442
  };
334
443
  }
335
444
  // ---------------------------------------------------------------------------
336
445
  // Private async helpers
337
446
  // ---------------------------------------------------------------------------
338
447
  async _startAsync(path, initialData, subPathMeta) {
339
- if (this._isNavigating)
448
+ if (this._status !== "idle")
340
449
  return;
341
450
  if (this.activePath !== null) {
342
451
  // Store the meta on the parent before pushing to stack
@@ -351,131 +460,147 @@ export class PathEngine {
351
460
  currentStepIndex: 0,
352
461
  data: { ...initialData },
353
462
  visitedStepIds: new Set(),
463
+ resolvedSkips: new Set(),
354
464
  subPathMeta: undefined,
355
465
  stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
356
466
  stepEnteredAt: 0 // Will be set in enterCurrentStep
357
467
  };
358
- this._isNavigating = true;
359
468
  await this.skipSteps(1);
360
469
  if (this.activePath.currentStepIndex >= path.steps.length) {
361
- this._isNavigating = false;
362
- await this.finishActivePath();
470
+ await this._finishActivePathWithErrorHandling();
363
471
  return;
364
472
  }
473
+ this._status = "entering";
365
474
  this.emitStateChanged("start");
366
- try {
367
- this.applyPatch(await this.enterCurrentStep());
368
- this._isNavigating = false;
369
- this.emitStateChanged("start");
370
- }
371
- catch (err) {
372
- this._isNavigating = false;
373
- this.emitStateChanged("start");
374
- throw err;
375
- }
475
+ await this._enterCurrentStepWithErrorHandling("start");
376
476
  }
377
477
  async _nextAsync(active) {
378
- if (this._isNavigating)
478
+ if (this._status !== "idle")
379
479
  return;
380
480
  // Record that the user has attempted to advance — used by shells and step
381
481
  // templates to gate error display ("punish late, reward early").
382
482
  this._hasAttemptedNext = true;
383
- this._isNavigating = true;
483
+ // Phase: validating — canMoveNext guard
484
+ this._status = "validating";
384
485
  this.emitStateChanged("next");
486
+ let guardResult;
385
487
  try {
386
- const step = this.getEffectiveStep(active);
387
- if (await this.canMoveNext(active, step)) {
388
- this.applyPatch(await this.leaveCurrentStep(active, step));
389
- active.currentStepIndex += 1;
390
- await this.skipSteps(1);
391
- if (active.currentStepIndex >= active.definition.steps.length) {
392
- this._isNavigating = false;
393
- await this.finishActivePath();
394
- return;
395
- }
396
- this.applyPatch(await this.enterCurrentStep());
397
- }
398
- this._isNavigating = false;
399
- this.emitStateChanged("next");
488
+ guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
400
489
  }
401
490
  catch (err) {
402
- this._isNavigating = false;
491
+ this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
492
+ this._pendingRetry = () => this._nextAsync(active);
493
+ this._status = "error";
403
494
  this.emitStateChanged("next");
404
- throw err;
495
+ return;
405
496
  }
497
+ if (guardResult.allowed) {
498
+ // Phase: leaving — onLeave hook
499
+ this._status = "leaving";
500
+ this.emitStateChanged("next");
501
+ try {
502
+ this.applyPatch(await this.leaveCurrentStep(active, this.getEffectiveStep(active)));
503
+ }
504
+ catch (err) {
505
+ this._error = { message: PathEngine.errorMessage(err), phase: "leaving", retryCount: this._retryCount };
506
+ this._pendingRetry = () => this._nextAsync(active);
507
+ this._status = "error";
508
+ this.emitStateChanged("next");
509
+ return;
510
+ }
511
+ active.currentStepIndex += 1;
512
+ await this.skipSteps(1);
513
+ if (active.currentStepIndex >= active.definition.steps.length) {
514
+ // Phase: completing — PathDefinition.onComplete
515
+ await this._finishActivePathWithErrorHandling();
516
+ return;
517
+ }
518
+ // Phase: entering — onEnter hook on the new step
519
+ await this._enterCurrentStepWithErrorHandling("next");
520
+ return;
521
+ }
522
+ this._blockingError = guardResult.reason;
523
+ this._status = "idle";
524
+ this.emitStateChanged("next");
406
525
  }
407
526
  async _previousAsync(active) {
408
- if (this._isNavigating)
527
+ if (this._status !== "idle")
409
528
  return;
410
529
  // No-op when already on the first step of a top-level path.
411
530
  // Sub-paths still cancel/pop back to the parent when previous() is called
412
531
  // on their first step (the currentStepIndex < 0 branch below handles that).
413
532
  if (active.currentStepIndex === 0 && this.pathStack.length === 0)
414
533
  return;
415
- this._isNavigating = true;
534
+ this._status = "leaving";
416
535
  this.emitStateChanged("previous");
417
536
  try {
418
537
  const step = this.getEffectiveStep(active);
419
- if (await this.canMovePrevious(active, step)) {
538
+ const prevGuard = await this.canMovePrevious(active, step);
539
+ if (!prevGuard.allowed)
540
+ this._blockingError = prevGuard.reason;
541
+ if (prevGuard.allowed) {
420
542
  this.applyPatch(await this.leaveCurrentStep(active, step));
421
543
  active.currentStepIndex -= 1;
422
544
  await this.skipSteps(-1);
423
545
  if (active.currentStepIndex < 0) {
424
- this._isNavigating = false;
546
+ this._status = "idle";
425
547
  await this.cancel();
426
548
  return;
427
549
  }
428
550
  this.applyPatch(await this.enterCurrentStep());
429
551
  }
430
- this._isNavigating = false;
552
+ this._status = "idle";
431
553
  this.emitStateChanged("previous");
432
554
  }
433
555
  catch (err) {
434
- this._isNavigating = false;
556
+ this._status = "idle";
435
557
  this.emitStateChanged("previous");
436
558
  throw err;
437
559
  }
438
560
  }
439
561
  async _goToStepAsync(active, targetIndex) {
440
- if (this._isNavigating)
562
+ if (this._status !== "idle")
441
563
  return;
442
- this._isNavigating = true;
564
+ this._status = "leaving";
443
565
  this.emitStateChanged("goToStep");
444
566
  try {
445
567
  const currentStep = this.getEffectiveStep(active);
446
568
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
447
569
  active.currentStepIndex = targetIndex;
448
570
  this.applyPatch(await this.enterCurrentStep());
449
- this._isNavigating = false;
571
+ this._status = "idle";
450
572
  this.emitStateChanged("goToStep");
451
573
  }
452
574
  catch (err) {
453
- this._isNavigating = false;
575
+ this._status = "idle";
454
576
  this.emitStateChanged("goToStep");
455
577
  throw err;
456
578
  }
457
579
  }
458
580
  async _goToStepCheckedAsync(active, targetIndex) {
459
- if (this._isNavigating)
581
+ if (this._status !== "idle")
460
582
  return;
461
- this._isNavigating = true;
583
+ this._status = "validating";
462
584
  this.emitStateChanged("goToStepChecked");
463
585
  try {
464
586
  const currentStep = this.getEffectiveStep(active);
465
587
  const goingForward = targetIndex > active.currentStepIndex;
466
- const allowed = goingForward
588
+ const guardResult = goingForward
467
589
  ? await this.canMoveNext(active, currentStep)
468
590
  : await this.canMovePrevious(active, currentStep);
469
- if (allowed) {
591
+ if (!guardResult.allowed)
592
+ this._blockingError = guardResult.reason;
593
+ if (guardResult.allowed) {
594
+ this._status = "leaving";
470
595
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
471
596
  active.currentStepIndex = targetIndex;
472
597
  this.applyPatch(await this.enterCurrentStep());
473
598
  }
474
- this._isNavigating = false;
599
+ this._status = "idle";
475
600
  this.emitStateChanged("goToStepChecked");
476
601
  }
477
602
  catch (err) {
478
- this._isNavigating = false;
603
+ this._status = "idle";
479
604
  this.emitStateChanged("goToStepChecked");
480
605
  throw err;
481
606
  }
@@ -485,7 +610,7 @@ export class PathEngine {
485
610
  // path (which has a valid currentStepIndex) rather than the cancelled
486
611
  // sub-path (which may have currentStepIndex = -1).
487
612
  this.activePath = this.pathStack.pop() ?? null;
488
- this._isNavigating = true;
613
+ this._status = "leaving";
489
614
  this.emitStateChanged("cancel");
490
615
  try {
491
616
  const parent = this.activePath;
@@ -502,11 +627,11 @@ export class PathEngine {
502
627
  this.applyPatch(await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta));
503
628
  }
504
629
  }
505
- this._isNavigating = false;
630
+ this._status = "idle";
506
631
  this.emitStateChanged("cancel");
507
632
  }
508
633
  catch (err) {
509
- this._isNavigating = false;
634
+ this._status = "idle";
510
635
  this.emitStateChanged("cancel");
511
636
  throw err;
512
637
  }
@@ -539,17 +664,75 @@ export class PathEngine {
539
664
  });
540
665
  }
541
666
  else {
542
- // Top-level path completed — call onComplete hook if defined
543
- this.activePath = null;
544
- this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
667
+ // Top-level path completed — call onComplete before clearing activePath so
668
+ // that if it throws the engine remains on the final step and can retry.
545
669
  if (finished.definition.onComplete) {
546
670
  await finished.definition.onComplete(finishedData);
547
671
  }
672
+ this.activePath = null;
673
+ this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
674
+ }
675
+ }
676
+ /**
677
+ * Wraps `finishActivePath` with error handling for the `completing` phase.
678
+ * On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
679
+ * resets status to `"error"`, and emits `stateChanged`.
680
+ * On success: resets status to `"idle"` (finishActivePath sets activePath = null,
681
+ * so no stateChanged is needed — the `completed` event is the terminal signal).
682
+ */
683
+ async _finishActivePathWithErrorHandling() {
684
+ const active = this.activePath;
685
+ this._status = "completing";
686
+ try {
687
+ await this.finishActivePath();
688
+ this._status = "idle";
689
+ // No stateChanged here — finishActivePath emits "completed" or "resumed"
690
+ }
691
+ catch (err) {
692
+ this._error = { message: PathEngine.errorMessage(err), phase: "completing", retryCount: this._retryCount };
693
+ // Retry: call finishActivePath again (activePath is still set because onComplete
694
+ // throws before this.activePath = null in the restructured finishActivePath)
695
+ this._pendingRetry = () => this._finishActivePathWithErrorHandling();
696
+ this._status = "error";
697
+ if (active) {
698
+ // Restore activePath if it was cleared mid-throw (defensive)
699
+ if (!this.activePath)
700
+ this.activePath = active;
701
+ // Back up to the last valid step so snapshot() can render it while error is shown
702
+ if (this.activePath.currentStepIndex >= this.activePath.definition.steps.length) {
703
+ this.activePath.currentStepIndex = this.activePath.definition.steps.length - 1;
704
+ }
705
+ this.emitStateChanged("next");
706
+ }
707
+ }
708
+ }
709
+ /**
710
+ * Wraps `enterCurrentStep` with error handling for the `entering` phase.
711
+ * Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
712
+ * On failure: sets `_error`, stores a retry that re-calls this method,
713
+ * resets status to `"error"`, and emits `stateChanged` with the given `cause`.
714
+ */
715
+ async _enterCurrentStepWithErrorHandling(cause) {
716
+ this._status = "entering";
717
+ try {
718
+ this.applyPatch(await this.enterCurrentStep());
719
+ this._status = "idle";
720
+ this.emitStateChanged(cause);
721
+ }
722
+ catch (err) {
723
+ this._error = { message: PathEngine.errorMessage(err), phase: "entering", retryCount: this._retryCount };
724
+ // Retry: re-enter the current step (don't repeat guards/leave)
725
+ this._pendingRetry = () => this._enterCurrentStepWithErrorHandling(cause);
726
+ this._status = "error";
727
+ this.emitStateChanged(cause);
548
728
  }
549
729
  }
550
730
  // ---------------------------------------------------------------------------
551
731
  // Private helpers
552
732
  // ---------------------------------------------------------------------------
733
+ static errorMessage(err) {
734
+ return err instanceof Error ? err.message : String(err);
735
+ }
553
736
  requireActivePath() {
554
737
  if (this.activePath === null) {
555
738
  throw new Error("No active path.");
@@ -636,23 +819,41 @@ export class PathEngine {
636
819
  while (active.currentStepIndex >= 0 &&
637
820
  active.currentStepIndex < active.definition.steps.length) {
638
821
  const item = active.definition.steps[active.currentStepIndex];
639
- if (!item.shouldSkip)
822
+ if (!item.shouldSkip) {
823
+ // This step has no shouldSkip — it is definitely visible. Remove it from
824
+ // the cache in case a previous navigation had marked it as skipped.
825
+ active.resolvedSkips.delete(item.id);
640
826
  break;
827
+ }
641
828
  const ctx = {
642
829
  pathId: active.definition.id,
643
830
  stepId: item.id,
644
831
  data: { ...active.data },
645
832
  isFirstEntry: !active.visitedStepIds.has(item.id)
646
833
  };
647
- const skip = await item.shouldSkip(ctx);
648
- if (!skip)
834
+ const rawResult = item.shouldSkip(ctx);
835
+ if (rawResult && typeof rawResult.then === "function") {
836
+ if (!this._hasWarnedAsyncShouldSkip) {
837
+ this._hasWarnedAsyncShouldSkip = true;
838
+ console.warn(`[Pathwrite] Step "${item.id}" has an async shouldSkip. ` +
839
+ `snapshot().stepCount and progress may be approximate until after the first navigation.`);
840
+ }
841
+ }
842
+ const skip = await rawResult;
843
+ if (!skip) {
844
+ // This step resolved as NOT skipped — remove from cache in case it was
845
+ // previously skipped (data changed since last navigation).
846
+ active.resolvedSkips.delete(item.id);
649
847
  break;
848
+ }
849
+ active.resolvedSkips.add(item.id);
650
850
  active.currentStepIndex += direction;
651
851
  }
652
852
  }
653
853
  async enterCurrentStep() {
654
854
  // Each step starts fresh — errors are not shown until the user attempts to proceed.
655
855
  this._hasAttemptedNext = false;
856
+ this._blockingError = null;
656
857
  const active = this.activePath;
657
858
  if (!active)
658
859
  return;
@@ -697,23 +898,31 @@ export class PathEngine {
697
898
  data: { ...active.data },
698
899
  isFirstEntry: !active.visitedStepIds.has(step.id)
699
900
  };
700
- return step.canMoveNext(ctx);
901
+ const result = await step.canMoveNext(ctx);
902
+ return PathEngine.normaliseGuardResult(result);
701
903
  }
702
904
  if (step.fieldErrors) {
703
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
905
+ const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
906
+ return { allowed, reason: null };
704
907
  }
705
- return true;
908
+ return { allowed: true, reason: null };
706
909
  }
707
910
  async canMovePrevious(active, step) {
708
911
  if (!step.canMovePrevious)
709
- return true;
912
+ return { allowed: true, reason: null };
710
913
  const ctx = {
711
914
  pathId: active.definition.id,
712
915
  stepId: step.id,
713
916
  data: { ...active.data },
714
917
  isFirstEntry: !active.visitedStepIds.has(step.id)
715
918
  };
716
- return step.canMovePrevious(ctx);
919
+ const result = await step.canMovePrevious(ctx);
920
+ return PathEngine.normaliseGuardResult(result);
921
+ }
922
+ static normaliseGuardResult(result) {
923
+ if (result === true)
924
+ return { allowed: true, reason: null };
925
+ return { allowed: false, reason: result.reason ?? null };
717
926
  }
718
927
  /**
719
928
  * Evaluates a guard function synchronously for inclusion in the snapshot.
@@ -741,16 +950,19 @@ export class PathEngine {
741
950
  };
742
951
  try {
743
952
  const result = guard(ctx);
744
- if (typeof result === "boolean")
745
- return result;
746
- // Async guard detected - warn and return optimistic default
953
+ if (result === true)
954
+ return true;
747
955
  if (result && typeof result.then === "function") {
956
+ // Async guard detected - suppress the unhandled rejection, warn, return optimistic default
957
+ result.catch(() => { });
748
958
  console.warn(`[pathwrite] Async guard detected on step "${item.id}". ` +
749
959
  `Guards in snapshots must be synchronous. ` +
750
960
  `Returning true (optimistic) as default. ` +
751
961
  `The async guard will still be enforced during actual navigation.`);
962
+ return true;
752
963
  }
753
- return true;
964
+ // { allowed: false, reason? } object returned synchronously
965
+ return false;
754
966
  }
755
967
  catch (err) {
756
968
  console.warn(`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +