@daltonr/pathwrite-core 0.9.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,16 +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;
43
68
  /** The path and initial data from the most recent top-level start() call. Used by restart(). */
44
69
  _rootPath = null;
45
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;
46
82
  constructor(options) {
47
83
  if (options?.observers) {
48
84
  for (const observer of options.observers) {
@@ -50,6 +86,9 @@ export class PathEngine {
50
86
  this.listeners.add((event) => observer(event, this));
51
87
  }
52
88
  }
89
+ if (options?.hasPersistence) {
90
+ this._hasPersistence = true;
91
+ }
53
92
  }
54
93
  /**
55
94
  * Restores a PathEngine from previously exported state.
@@ -82,6 +121,7 @@ export class PathEngine {
82
121
  currentStepIndex: stackItem.currentStepIndex,
83
122
  data: { ...stackItem.data },
84
123
  visitedStepIds: new Set(stackItem.visitedStepIds),
124
+ resolvedSkips: new Set(),
85
125
  subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
86
126
  stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
87
127
  stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
@@ -97,13 +137,14 @@ export class PathEngine {
97
137
  currentStepIndex: state.currentStepIndex,
98
138
  data: { ...state.data },
99
139
  visitedStepIds: new Set(state.visitedStepIds),
140
+ resolvedSkips: new Set(),
100
141
  // Active path's subPathMeta is not serialized (it's transient metadata
101
142
  // from the parent when this path was started). On restore, it's undefined.
102
143
  subPathMeta: undefined,
103
144
  stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
104
145
  stepEnteredAt: state.stepEnteredAt ?? Date.now()
105
146
  };
106
- engine._isNavigating = state._isNavigating;
147
+ engine._status = state._status ?? "idle";
107
148
  // Re-derive the selected inner step for any StepChoice slots (not serialized —
108
149
  // always recomputed from current data on restore).
109
150
  for (const stackItem of engine.pathStack) {
@@ -140,7 +181,8 @@ export class PathEngine {
140
181
  if (!this._rootPath) {
141
182
  throw new Error("Cannot restart: engine has not been started. Call start() first.");
142
183
  }
143
- this._isNavigating = false;
184
+ this._status = "idle";
185
+ this._blockingError = null;
144
186
  this.activePath = null;
145
187
  this.pathStack.length = 0;
146
188
  return this._startAsync(this._rootPath, { ...this._rootInitialData });
@@ -163,18 +205,68 @@ export class PathEngine {
163
205
  }
164
206
  next() {
165
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";
166
217
  return this._nextAsync(active);
167
218
  }
168
219
  previous() {
169
220
  const active = this.requireActivePath();
170
221
  return this._previousAsync(active);
171
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
+ }
172
264
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
173
265
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
174
266
  * API consistency. */
175
267
  async cancel() {
176
268
  const active = this.requireActivePath();
177
- if (this._isNavigating)
269
+ if (this._status !== "idle")
178
270
  return;
179
271
  const cancelledPathId = active.definition.id;
180
272
  const cancelledData = { ...active.data };
@@ -247,24 +339,32 @@ export class PathEngine {
247
339
  const item = this.getCurrentItem(active);
248
340
  const effectiveStep = this.getEffectiveStep(active);
249
341
  const { steps } = active.definition;
250
- 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;
251
349
  // Build rootProgress from the bottom of the stack (the top-level path)
252
350
  let rootProgress;
253
351
  if (this.pathStack.length > 0) {
254
352
  const root = this.pathStack[0];
255
- const rootSteps = root.definition.steps;
256
- 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;
257
357
  rootProgress = {
258
358
  pathId: root.definition.id,
259
- stepIndex: root.currentStepIndex,
359
+ stepIndex: rootEffectiveIndex,
260
360
  stepCount: rootStepCount,
261
- progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
262
- steps: rootSteps.map((s, i) => ({
361
+ progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
362
+ steps: rootVisibleSteps.map((s, i) => ({
263
363
  id: s.id,
264
364
  title: s.title,
265
365
  meta: s.meta,
266
- status: i < root.currentStepIndex ? "completed"
267
- : i === root.currentStepIndex ? "current"
366
+ status: i < rootEffectiveIndex ? "completed"
367
+ : i === rootEffectiveIndex ? "current"
268
368
  : "upcoming"
269
369
  }))
270
370
  };
@@ -275,24 +375,27 @@ export class PathEngine {
275
375
  stepTitle: effectiveStep.title ?? item.title,
276
376
  stepMeta: effectiveStep.meta ?? item.meta,
277
377
  formId: isStepChoice(item) ? effectiveStep.id : undefined,
278
- stepIndex: active.currentStepIndex,
378
+ stepIndex: effectiveStepIndex,
279
379
  stepCount,
280
- progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
281
- steps: steps.map((s, i) => ({
380
+ progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
381
+ steps: visibleSteps.map((s, i) => ({
282
382
  id: s.id,
283
383
  title: s.title,
284
384
  meta: s.meta,
285
- status: i < active.currentStepIndex ? "completed"
286
- : i === active.currentStepIndex ? "current"
385
+ status: i < effectiveStepIndex ? "completed"
386
+ : i === effectiveStepIndex ? "current"
287
387
  : "upcoming"
288
388
  })),
289
- isFirstStep: active.currentStepIndex === 0,
290
- isLastStep: active.currentStepIndex === stepCount - 1 &&
389
+ isFirstStep: effectiveStepIndex === 0,
390
+ isLastStep: effectiveStepIndex === stepCount - 1 &&
291
391
  this.pathStack.length === 0,
292
392
  nestingLevel: this.pathStack.length,
293
393
  rootProgress,
294
- isNavigating: this._isNavigating,
394
+ status: this._status,
395
+ error: this._error,
396
+ hasPersistence: this._hasPersistence,
295
397
  hasAttemptedNext: this._hasAttemptedNext,
398
+ blockingError: this._blockingError,
296
399
  canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
297
400
  canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
298
401
  fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
@@ -335,14 +438,14 @@ export class PathEngine {
335
438
  stepEntryData: { ...p.stepEntryData },
336
439
  stepEnteredAt: p.stepEnteredAt
337
440
  })),
338
- _isNavigating: this._isNavigating
441
+ _status: this._status
339
442
  };
340
443
  }
341
444
  // ---------------------------------------------------------------------------
342
445
  // Private async helpers
343
446
  // ---------------------------------------------------------------------------
344
447
  async _startAsync(path, initialData, subPathMeta) {
345
- if (this._isNavigating)
448
+ if (this._status !== "idle")
346
449
  return;
347
450
  if (this.activePath !== null) {
348
451
  // Store the meta on the parent before pushing to stack
@@ -357,131 +460,147 @@ export class PathEngine {
357
460
  currentStepIndex: 0,
358
461
  data: { ...initialData },
359
462
  visitedStepIds: new Set(),
463
+ resolvedSkips: new Set(),
360
464
  subPathMeta: undefined,
361
465
  stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
362
466
  stepEnteredAt: 0 // Will be set in enterCurrentStep
363
467
  };
364
- this._isNavigating = true;
365
468
  await this.skipSteps(1);
366
469
  if (this.activePath.currentStepIndex >= path.steps.length) {
367
- this._isNavigating = false;
368
- await this.finishActivePath();
470
+ await this._finishActivePathWithErrorHandling();
369
471
  return;
370
472
  }
473
+ this._status = "entering";
371
474
  this.emitStateChanged("start");
372
- try {
373
- this.applyPatch(await this.enterCurrentStep());
374
- this._isNavigating = false;
375
- this.emitStateChanged("start");
376
- }
377
- catch (err) {
378
- this._isNavigating = false;
379
- this.emitStateChanged("start");
380
- throw err;
381
- }
475
+ await this._enterCurrentStepWithErrorHandling("start");
382
476
  }
383
477
  async _nextAsync(active) {
384
- if (this._isNavigating)
478
+ if (this._status !== "idle")
385
479
  return;
386
480
  // Record that the user has attempted to advance — used by shells and step
387
481
  // templates to gate error display ("punish late, reward early").
388
482
  this._hasAttemptedNext = true;
389
- this._isNavigating = true;
483
+ // Phase: validating — canMoveNext guard
484
+ this._status = "validating";
390
485
  this.emitStateChanged("next");
486
+ let guardResult;
391
487
  try {
392
- const step = this.getEffectiveStep(active);
393
- if (await this.canMoveNext(active, step)) {
394
- this.applyPatch(await this.leaveCurrentStep(active, step));
395
- active.currentStepIndex += 1;
396
- await this.skipSteps(1);
397
- if (active.currentStepIndex >= active.definition.steps.length) {
398
- this._isNavigating = false;
399
- await this.finishActivePath();
400
- return;
401
- }
402
- this.applyPatch(await this.enterCurrentStep());
403
- }
404
- this._isNavigating = false;
405
- this.emitStateChanged("next");
488
+ guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
406
489
  }
407
490
  catch (err) {
408
- 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";
409
494
  this.emitStateChanged("next");
410
- throw err;
495
+ return;
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;
411
521
  }
522
+ this._blockingError = guardResult.reason;
523
+ this._status = "idle";
524
+ this.emitStateChanged("next");
412
525
  }
413
526
  async _previousAsync(active) {
414
- if (this._isNavigating)
527
+ if (this._status !== "idle")
415
528
  return;
416
529
  // No-op when already on the first step of a top-level path.
417
530
  // Sub-paths still cancel/pop back to the parent when previous() is called
418
531
  // on their first step (the currentStepIndex < 0 branch below handles that).
419
532
  if (active.currentStepIndex === 0 && this.pathStack.length === 0)
420
533
  return;
421
- this._isNavigating = true;
534
+ this._status = "leaving";
422
535
  this.emitStateChanged("previous");
423
536
  try {
424
537
  const step = this.getEffectiveStep(active);
425
- 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) {
426
542
  this.applyPatch(await this.leaveCurrentStep(active, step));
427
543
  active.currentStepIndex -= 1;
428
544
  await this.skipSteps(-1);
429
545
  if (active.currentStepIndex < 0) {
430
- this._isNavigating = false;
546
+ this._status = "idle";
431
547
  await this.cancel();
432
548
  return;
433
549
  }
434
550
  this.applyPatch(await this.enterCurrentStep());
435
551
  }
436
- this._isNavigating = false;
552
+ this._status = "idle";
437
553
  this.emitStateChanged("previous");
438
554
  }
439
555
  catch (err) {
440
- this._isNavigating = false;
556
+ this._status = "idle";
441
557
  this.emitStateChanged("previous");
442
558
  throw err;
443
559
  }
444
560
  }
445
561
  async _goToStepAsync(active, targetIndex) {
446
- if (this._isNavigating)
562
+ if (this._status !== "idle")
447
563
  return;
448
- this._isNavigating = true;
564
+ this._status = "leaving";
449
565
  this.emitStateChanged("goToStep");
450
566
  try {
451
567
  const currentStep = this.getEffectiveStep(active);
452
568
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
453
569
  active.currentStepIndex = targetIndex;
454
570
  this.applyPatch(await this.enterCurrentStep());
455
- this._isNavigating = false;
571
+ this._status = "idle";
456
572
  this.emitStateChanged("goToStep");
457
573
  }
458
574
  catch (err) {
459
- this._isNavigating = false;
575
+ this._status = "idle";
460
576
  this.emitStateChanged("goToStep");
461
577
  throw err;
462
578
  }
463
579
  }
464
580
  async _goToStepCheckedAsync(active, targetIndex) {
465
- if (this._isNavigating)
581
+ if (this._status !== "idle")
466
582
  return;
467
- this._isNavigating = true;
583
+ this._status = "validating";
468
584
  this.emitStateChanged("goToStepChecked");
469
585
  try {
470
586
  const currentStep = this.getEffectiveStep(active);
471
587
  const goingForward = targetIndex > active.currentStepIndex;
472
- const allowed = goingForward
588
+ const guardResult = goingForward
473
589
  ? await this.canMoveNext(active, currentStep)
474
590
  : await this.canMovePrevious(active, currentStep);
475
- if (allowed) {
591
+ if (!guardResult.allowed)
592
+ this._blockingError = guardResult.reason;
593
+ if (guardResult.allowed) {
594
+ this._status = "leaving";
476
595
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
477
596
  active.currentStepIndex = targetIndex;
478
597
  this.applyPatch(await this.enterCurrentStep());
479
598
  }
480
- this._isNavigating = false;
599
+ this._status = "idle";
481
600
  this.emitStateChanged("goToStepChecked");
482
601
  }
483
602
  catch (err) {
484
- this._isNavigating = false;
603
+ this._status = "idle";
485
604
  this.emitStateChanged("goToStepChecked");
486
605
  throw err;
487
606
  }
@@ -491,7 +610,7 @@ export class PathEngine {
491
610
  // path (which has a valid currentStepIndex) rather than the cancelled
492
611
  // sub-path (which may have currentStepIndex = -1).
493
612
  this.activePath = this.pathStack.pop() ?? null;
494
- this._isNavigating = true;
613
+ this._status = "leaving";
495
614
  this.emitStateChanged("cancel");
496
615
  try {
497
616
  const parent = this.activePath;
@@ -508,11 +627,11 @@ export class PathEngine {
508
627
  this.applyPatch(await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta));
509
628
  }
510
629
  }
511
- this._isNavigating = false;
630
+ this._status = "idle";
512
631
  this.emitStateChanged("cancel");
513
632
  }
514
633
  catch (err) {
515
- this._isNavigating = false;
634
+ this._status = "idle";
516
635
  this.emitStateChanged("cancel");
517
636
  throw err;
518
637
  }
@@ -545,17 +664,75 @@ export class PathEngine {
545
664
  });
546
665
  }
547
666
  else {
548
- // Top-level path completed — call onComplete hook if defined
549
- this.activePath = null;
550
- 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.
551
669
  if (finished.definition.onComplete) {
552
670
  await finished.definition.onComplete(finishedData);
553
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);
554
728
  }
555
729
  }
556
730
  // ---------------------------------------------------------------------------
557
731
  // Private helpers
558
732
  // ---------------------------------------------------------------------------
733
+ static errorMessage(err) {
734
+ return err instanceof Error ? err.message : String(err);
735
+ }
559
736
  requireActivePath() {
560
737
  if (this.activePath === null) {
561
738
  throw new Error("No active path.");
@@ -642,23 +819,41 @@ export class PathEngine {
642
819
  while (active.currentStepIndex >= 0 &&
643
820
  active.currentStepIndex < active.definition.steps.length) {
644
821
  const item = active.definition.steps[active.currentStepIndex];
645
- 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);
646
826
  break;
827
+ }
647
828
  const ctx = {
648
829
  pathId: active.definition.id,
649
830
  stepId: item.id,
650
831
  data: { ...active.data },
651
832
  isFirstEntry: !active.visitedStepIds.has(item.id)
652
833
  };
653
- const skip = await item.shouldSkip(ctx);
654
- 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);
655
847
  break;
848
+ }
849
+ active.resolvedSkips.add(item.id);
656
850
  active.currentStepIndex += direction;
657
851
  }
658
852
  }
659
853
  async enterCurrentStep() {
660
854
  // Each step starts fresh — errors are not shown until the user attempts to proceed.
661
855
  this._hasAttemptedNext = false;
856
+ this._blockingError = null;
662
857
  const active = this.activePath;
663
858
  if (!active)
664
859
  return;
@@ -703,23 +898,31 @@ export class PathEngine {
703
898
  data: { ...active.data },
704
899
  isFirstEntry: !active.visitedStepIds.has(step.id)
705
900
  };
706
- return step.canMoveNext(ctx);
901
+ const result = await step.canMoveNext(ctx);
902
+ return PathEngine.normaliseGuardResult(result);
707
903
  }
708
904
  if (step.fieldErrors) {
709
- 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 };
710
907
  }
711
- return true;
908
+ return { allowed: true, reason: null };
712
909
  }
713
910
  async canMovePrevious(active, step) {
714
911
  if (!step.canMovePrevious)
715
- return true;
912
+ return { allowed: true, reason: null };
716
913
  const ctx = {
717
914
  pathId: active.definition.id,
718
915
  stepId: step.id,
719
916
  data: { ...active.data },
720
917
  isFirstEntry: !active.visitedStepIds.has(step.id)
721
918
  };
722
- 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 };
723
926
  }
724
927
  /**
725
928
  * Evaluates a guard function synchronously for inclusion in the snapshot.
@@ -747,16 +950,19 @@ export class PathEngine {
747
950
  };
748
951
  try {
749
952
  const result = guard(ctx);
750
- if (typeof result === "boolean")
751
- return result;
752
- // Async guard detected - warn and return optimistic default
953
+ if (result === true)
954
+ return true;
753
955
  if (result && typeof result.then === "function") {
956
+ // Async guard detected - suppress the unhandled rejection, warn, return optimistic default
957
+ result.catch(() => { });
754
958
  console.warn(`[pathwrite] Async guard detected on step "${item.id}". ` +
755
959
  `Guards in snapshots must be synchronous. ` +
756
960
  `Returning true (optimistic) as default. ` +
757
961
  `The async guard will still be enforced during actual navigation.`);
962
+ return true;
758
963
  }
759
- return true;
964
+ // { allowed: false, reason? } object returned synchronously
965
+ return false;
760
966
  }
761
967
  catch (err) {
762
968
  console.warn(`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +