@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.d.ts +158 -7
- package/dist/index.js +297 -91
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +404 -101
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,7 +48,7 @@ export interface SerializedPathState {
|
|
|
29
48
|
stepEntryData?: PathData;
|
|
30
49
|
stepEnteredAt?: number;
|
|
31
50
|
}>;
|
|
32
|
-
|
|
51
|
+
_status: PathStatus;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
/**
|
|
@@ -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>) =>
|
|
109
|
-
canMovePrevious?: (ctx: PathStepContext<TData>) =>
|
|
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
|
-
/**
|
|
257
|
-
|
|
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
|
|
400
|
-
//
|
|
401
|
-
return (event.type === "stateChanged" &&
|
|
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
|
-
&&
|
|
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,22 +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
|
|
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;
|
|
454
570
|
/** The path and initial data from the most recent top-level start() call. Used by restart(). */
|
|
455
571
|
private _rootPath: PathDefinition<any> | null = null;
|
|
456
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;
|
|
457
584
|
|
|
458
585
|
constructor(options?: PathEngineOptions) {
|
|
459
586
|
if (options?.observers) {
|
|
@@ -462,6 +589,9 @@ export class PathEngine {
|
|
|
462
589
|
this.listeners.add((event) => observer(event, this));
|
|
463
590
|
}
|
|
464
591
|
}
|
|
592
|
+
if (options?.hasPersistence) {
|
|
593
|
+
this._hasPersistence = true;
|
|
594
|
+
}
|
|
465
595
|
}
|
|
466
596
|
|
|
467
597
|
/**
|
|
@@ -503,6 +633,7 @@ export class PathEngine {
|
|
|
503
633
|
currentStepIndex: stackItem.currentStepIndex,
|
|
504
634
|
data: { ...stackItem.data },
|
|
505
635
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
636
|
+
resolvedSkips: new Set(),
|
|
506
637
|
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
507
638
|
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
508
639
|
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
@@ -522,6 +653,7 @@ export class PathEngine {
|
|
|
522
653
|
currentStepIndex: state.currentStepIndex,
|
|
523
654
|
data: { ...state.data },
|
|
524
655
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
656
|
+
resolvedSkips: new Set(),
|
|
525
657
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
526
658
|
// from the parent when this path was started). On restore, it's undefined.
|
|
527
659
|
subPathMeta: undefined,
|
|
@@ -529,7 +661,7 @@ export class PathEngine {
|
|
|
529
661
|
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
530
662
|
};
|
|
531
663
|
|
|
532
|
-
engine.
|
|
664
|
+
engine._status = state._status ?? "idle";
|
|
533
665
|
|
|
534
666
|
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
535
667
|
// always recomputed from current data on restore).
|
|
@@ -572,7 +704,8 @@ export class PathEngine {
|
|
|
572
704
|
if (!this._rootPath) {
|
|
573
705
|
throw new Error("Cannot restart: engine has not been started. Call start() first.");
|
|
574
706
|
}
|
|
575
|
-
this.
|
|
707
|
+
this._status = "idle";
|
|
708
|
+
this._blockingError = null;
|
|
576
709
|
this.activePath = null;
|
|
577
710
|
this.pathStack.length = 0;
|
|
578
711
|
return this._startAsync(this._rootPath, { ...this._rootInitialData });
|
|
@@ -597,6 +730,14 @@ export class PathEngine {
|
|
|
597
730
|
|
|
598
731
|
public next(): Promise<void> {
|
|
599
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";
|
|
600
741
|
return this._nextAsync(active);
|
|
601
742
|
}
|
|
602
743
|
|
|
@@ -605,12 +746,54 @@ export class PathEngine {
|
|
|
605
746
|
return this._previousAsync(active);
|
|
606
747
|
}
|
|
607
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
|
+
|
|
608
791
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
609
792
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
610
793
|
* API consistency. */
|
|
611
794
|
public async cancel(): Promise<void> {
|
|
612
795
|
const active = this.requireActivePath();
|
|
613
|
-
if (this.
|
|
796
|
+
if (this._status !== "idle") return;
|
|
614
797
|
|
|
615
798
|
const cancelledPathId = active.definition.id;
|
|
616
799
|
const cancelledData = { ...active.data };
|
|
@@ -690,25 +873,34 @@ export class PathEngine {
|
|
|
690
873
|
const item = this.getCurrentItem(active);
|
|
691
874
|
const effectiveStep = this.getEffectiveStep(active);
|
|
692
875
|
const { steps } = active.definition;
|
|
693
|
-
|
|
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;
|
|
694
884
|
|
|
695
885
|
// Build rootProgress from the bottom of the stack (the top-level path)
|
|
696
886
|
let rootProgress: RootProgress | undefined;
|
|
697
887
|
if (this.pathStack.length > 0) {
|
|
698
888
|
const root = this.pathStack[0];
|
|
699
|
-
const
|
|
700
|
-
const rootStepCount =
|
|
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;
|
|
701
893
|
rootProgress = {
|
|
702
894
|
pathId: root.definition.id,
|
|
703
|
-
stepIndex:
|
|
895
|
+
stepIndex: rootEffectiveIndex,
|
|
704
896
|
stepCount: rootStepCount,
|
|
705
|
-
progress: rootStepCount <= 1 ? 1 :
|
|
706
|
-
steps:
|
|
897
|
+
progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
|
|
898
|
+
steps: rootVisibleSteps.map((s, i) => ({
|
|
707
899
|
id: s.id,
|
|
708
900
|
title: s.title,
|
|
709
901
|
meta: s.meta,
|
|
710
|
-
status: i <
|
|
711
|
-
: i ===
|
|
902
|
+
status: i < rootEffectiveIndex ? "completed" as const
|
|
903
|
+
: i === rootEffectiveIndex ? "current" as const
|
|
712
904
|
: "upcoming" as const
|
|
713
905
|
}))
|
|
714
906
|
};
|
|
@@ -720,25 +912,28 @@ export class PathEngine {
|
|
|
720
912
|
stepTitle: effectiveStep.title ?? item.title,
|
|
721
913
|
stepMeta: effectiveStep.meta ?? item.meta,
|
|
722
914
|
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
723
|
-
stepIndex:
|
|
915
|
+
stepIndex: effectiveStepIndex,
|
|
724
916
|
stepCount,
|
|
725
|
-
progress: stepCount <= 1 ? 1 :
|
|
726
|
-
steps:
|
|
917
|
+
progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
|
|
918
|
+
steps: visibleSteps.map((s, i) => ({
|
|
727
919
|
id: s.id,
|
|
728
920
|
title: s.title,
|
|
729
921
|
meta: s.meta,
|
|
730
|
-
status: i <
|
|
731
|
-
: i ===
|
|
922
|
+
status: i < effectiveStepIndex ? "completed" as const
|
|
923
|
+
: i === effectiveStepIndex ? "current" as const
|
|
732
924
|
: "upcoming" as const
|
|
733
925
|
})),
|
|
734
|
-
isFirstStep:
|
|
926
|
+
isFirstStep: effectiveStepIndex === 0,
|
|
735
927
|
isLastStep:
|
|
736
|
-
|
|
928
|
+
effectiveStepIndex === stepCount - 1 &&
|
|
737
929
|
this.pathStack.length === 0,
|
|
738
930
|
nestingLevel: this.pathStack.length,
|
|
739
931
|
rootProgress,
|
|
740
|
-
|
|
932
|
+
status: this._status,
|
|
933
|
+
error: this._error,
|
|
934
|
+
hasPersistence: this._hasPersistence,
|
|
741
935
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
936
|
+
blockingError: this._blockingError,
|
|
742
937
|
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
743
938
|
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
744
939
|
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
@@ -784,7 +979,7 @@ export class PathEngine {
|
|
|
784
979
|
stepEntryData: { ...p.stepEntryData },
|
|
785
980
|
stepEnteredAt: p.stepEnteredAt
|
|
786
981
|
})),
|
|
787
|
-
|
|
982
|
+
_status: this._status
|
|
788
983
|
};
|
|
789
984
|
}
|
|
790
985
|
|
|
@@ -793,7 +988,7 @@ export class PathEngine {
|
|
|
793
988
|
// ---------------------------------------------------------------------------
|
|
794
989
|
|
|
795
990
|
private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
|
|
796
|
-
if (this.
|
|
991
|
+
if (this._status !== "idle") return;
|
|
797
992
|
|
|
798
993
|
if (this.activePath !== null) {
|
|
799
994
|
// Store the meta on the parent before pushing to stack
|
|
@@ -809,90 +1004,104 @@ export class PathEngine {
|
|
|
809
1004
|
currentStepIndex: 0,
|
|
810
1005
|
data: { ...initialData },
|
|
811
1006
|
visitedStepIds: new Set(),
|
|
1007
|
+
resolvedSkips: new Set(),
|
|
812
1008
|
subPathMeta: undefined,
|
|
813
1009
|
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
814
1010
|
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
815
1011
|
};
|
|
816
1012
|
|
|
817
|
-
this._isNavigating = true;
|
|
818
|
-
|
|
819
1013
|
await this.skipSteps(1);
|
|
820
1014
|
|
|
821
1015
|
if (this.activePath.currentStepIndex >= path.steps.length) {
|
|
822
|
-
this.
|
|
823
|
-
await this.finishActivePath();
|
|
1016
|
+
await this._finishActivePathWithErrorHandling();
|
|
824
1017
|
return;
|
|
825
1018
|
}
|
|
826
1019
|
|
|
1020
|
+
this._status = "entering";
|
|
827
1021
|
this.emitStateChanged("start");
|
|
828
1022
|
|
|
829
|
-
|
|
830
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
831
|
-
this._isNavigating = false;
|
|
832
|
-
this.emitStateChanged("start");
|
|
833
|
-
} catch (err) {
|
|
834
|
-
this._isNavigating = false;
|
|
835
|
-
this.emitStateChanged("start");
|
|
836
|
-
throw err;
|
|
837
|
-
}
|
|
1023
|
+
await this._enterCurrentStepWithErrorHandling("start");
|
|
838
1024
|
}
|
|
839
1025
|
|
|
840
1026
|
private async _nextAsync(active: ActivePath): Promise<void> {
|
|
841
|
-
if (this.
|
|
1027
|
+
if (this._status !== "idle") return;
|
|
842
1028
|
|
|
843
1029
|
// Record that the user has attempted to advance — used by shells and step
|
|
844
1030
|
// templates to gate error display ("punish late, reward early").
|
|
845
1031
|
this._hasAttemptedNext = true;
|
|
846
|
-
|
|
1032
|
+
|
|
1033
|
+
// Phase: validating — canMoveNext guard
|
|
1034
|
+
this._status = "validating";
|
|
847
1035
|
this.emitStateChanged("next");
|
|
848
1036
|
|
|
1037
|
+
let guardResult: { allowed: boolean; reason: string | null };
|
|
849
1038
|
try {
|
|
850
|
-
|
|
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
|
+
}
|
|
851
1047
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
+
}
|
|
856
1061
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
await this.finishActivePath();
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
1062
|
+
active.currentStepIndex += 1;
|
|
1063
|
+
await this.skipSteps(1);
|
|
862
1064
|
|
|
863
|
-
|
|
1065
|
+
if (active.currentStepIndex >= active.definition.steps.length) {
|
|
1066
|
+
// Phase: completing — PathDefinition.onComplete
|
|
1067
|
+
await this._finishActivePathWithErrorHandling();
|
|
1068
|
+
return;
|
|
864
1069
|
}
|
|
865
1070
|
|
|
866
|
-
|
|
867
|
-
this.
|
|
868
|
-
|
|
869
|
-
this._isNavigating = false;
|
|
870
|
-
this.emitStateChanged("next");
|
|
871
|
-
throw err;
|
|
1071
|
+
// Phase: entering — onEnter hook on the new step
|
|
1072
|
+
await this._enterCurrentStepWithErrorHandling("next");
|
|
1073
|
+
return;
|
|
872
1074
|
}
|
|
1075
|
+
|
|
1076
|
+
this._blockingError = guardResult.reason;
|
|
1077
|
+
this._status = "idle";
|
|
1078
|
+
this.emitStateChanged("next");
|
|
873
1079
|
}
|
|
874
1080
|
|
|
875
1081
|
private async _previousAsync(active: ActivePath): Promise<void> {
|
|
876
|
-
if (this.
|
|
1082
|
+
if (this._status !== "idle") return;
|
|
877
1083
|
|
|
878
1084
|
// No-op when already on the first step of a top-level path.
|
|
879
1085
|
// Sub-paths still cancel/pop back to the parent when previous() is called
|
|
880
1086
|
// on their first step (the currentStepIndex < 0 branch below handles that).
|
|
881
1087
|
if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
|
|
882
1088
|
|
|
883
|
-
this.
|
|
1089
|
+
this._status = "leaving";
|
|
884
1090
|
this.emitStateChanged("previous");
|
|
885
1091
|
|
|
886
1092
|
try {
|
|
887
1093
|
const step = this.getEffectiveStep(active);
|
|
888
1094
|
|
|
889
|
-
|
|
1095
|
+
const prevGuard = await this.canMovePrevious(active, step);
|
|
1096
|
+
if (!prevGuard.allowed) this._blockingError = prevGuard.reason;
|
|
1097
|
+
|
|
1098
|
+
if (prevGuard.allowed) {
|
|
890
1099
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
891
1100
|
active.currentStepIndex -= 1;
|
|
892
1101
|
await this.skipSteps(-1);
|
|
893
1102
|
|
|
894
1103
|
if (active.currentStepIndex < 0) {
|
|
895
|
-
this.
|
|
1104
|
+
this._status = "idle";
|
|
896
1105
|
await this.cancel();
|
|
897
1106
|
return;
|
|
898
1107
|
}
|
|
@@ -900,19 +1109,19 @@ export class PathEngine {
|
|
|
900
1109
|
this.applyPatch(await this.enterCurrentStep());
|
|
901
1110
|
}
|
|
902
1111
|
|
|
903
|
-
this.
|
|
1112
|
+
this._status = "idle";
|
|
904
1113
|
this.emitStateChanged("previous");
|
|
905
1114
|
} catch (err) {
|
|
906
|
-
this.
|
|
1115
|
+
this._status = "idle";
|
|
907
1116
|
this.emitStateChanged("previous");
|
|
908
1117
|
throw err;
|
|
909
1118
|
}
|
|
910
1119
|
}
|
|
911
1120
|
|
|
912
1121
|
private async _goToStepAsync(active: ActivePath, targetIndex: number): Promise<void> {
|
|
913
|
-
if (this.
|
|
1122
|
+
if (this._status !== "idle") return;
|
|
914
1123
|
|
|
915
|
-
this.
|
|
1124
|
+
this._status = "leaving";
|
|
916
1125
|
this.emitStateChanged("goToStep");
|
|
917
1126
|
|
|
918
1127
|
try {
|
|
@@ -922,38 +1131,41 @@ export class PathEngine {
|
|
|
922
1131
|
active.currentStepIndex = targetIndex;
|
|
923
1132
|
|
|
924
1133
|
this.applyPatch(await this.enterCurrentStep());
|
|
925
|
-
this.
|
|
1134
|
+
this._status = "idle";
|
|
926
1135
|
this.emitStateChanged("goToStep");
|
|
927
1136
|
} catch (err) {
|
|
928
|
-
this.
|
|
1137
|
+
this._status = "idle";
|
|
929
1138
|
this.emitStateChanged("goToStep");
|
|
930
1139
|
throw err;
|
|
931
1140
|
}
|
|
932
1141
|
}
|
|
933
1142
|
|
|
934
1143
|
private async _goToStepCheckedAsync(active: ActivePath, targetIndex: number): Promise<void> {
|
|
935
|
-
if (this.
|
|
1144
|
+
if (this._status !== "idle") return;
|
|
936
1145
|
|
|
937
|
-
this.
|
|
1146
|
+
this._status = "validating";
|
|
938
1147
|
this.emitStateChanged("goToStepChecked");
|
|
939
1148
|
|
|
940
1149
|
try {
|
|
941
1150
|
const currentStep = this.getEffectiveStep(active);
|
|
942
1151
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
943
|
-
const
|
|
1152
|
+
const guardResult = goingForward
|
|
944
1153
|
? await this.canMoveNext(active, currentStep)
|
|
945
1154
|
: await this.canMovePrevious(active, currentStep);
|
|
946
1155
|
|
|
947
|
-
if (allowed)
|
|
1156
|
+
if (!guardResult.allowed) this._blockingError = guardResult.reason;
|
|
1157
|
+
|
|
1158
|
+
if (guardResult.allowed) {
|
|
1159
|
+
this._status = "leaving";
|
|
948
1160
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
949
1161
|
active.currentStepIndex = targetIndex;
|
|
950
1162
|
this.applyPatch(await this.enterCurrentStep());
|
|
951
1163
|
}
|
|
952
1164
|
|
|
953
|
-
this.
|
|
1165
|
+
this._status = "idle";
|
|
954
1166
|
this.emitStateChanged("goToStepChecked");
|
|
955
1167
|
} catch (err) {
|
|
956
|
-
this.
|
|
1168
|
+
this._status = "idle";
|
|
957
1169
|
this.emitStateChanged("goToStepChecked");
|
|
958
1170
|
throw err;
|
|
959
1171
|
}
|
|
@@ -969,7 +1181,7 @@ export class PathEngine {
|
|
|
969
1181
|
// sub-path (which may have currentStepIndex = -1).
|
|
970
1182
|
this.activePath = this.pathStack.pop() ?? null;
|
|
971
1183
|
|
|
972
|
-
this.
|
|
1184
|
+
this._status = "leaving";
|
|
973
1185
|
this.emitStateChanged("cancel");
|
|
974
1186
|
|
|
975
1187
|
try {
|
|
@@ -991,10 +1203,10 @@ export class PathEngine {
|
|
|
991
1203
|
}
|
|
992
1204
|
}
|
|
993
1205
|
|
|
994
|
-
this.
|
|
1206
|
+
this._status = "idle";
|
|
995
1207
|
this.emitStateChanged("cancel");
|
|
996
1208
|
} catch (err) {
|
|
997
|
-
this.
|
|
1209
|
+
this._status = "idle";
|
|
998
1210
|
this.emitStateChanged("cancel");
|
|
999
1211
|
throw err;
|
|
1000
1212
|
}
|
|
@@ -1032,12 +1244,66 @@ export class PathEngine {
|
|
|
1032
1244
|
snapshot: this.snapshot()!
|
|
1033
1245
|
});
|
|
1034
1246
|
} else {
|
|
1035
|
-
// Top-level path completed — call onComplete
|
|
1036
|
-
|
|
1037
|
-
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.
|
|
1038
1249
|
if (finished.definition.onComplete) {
|
|
1039
1250
|
await finished.definition.onComplete(finishedData);
|
|
1040
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);
|
|
1041
1307
|
}
|
|
1042
1308
|
}
|
|
1043
1309
|
|
|
@@ -1045,6 +1311,10 @@ export class PathEngine {
|
|
|
1045
1311
|
// Private helpers
|
|
1046
1312
|
// ---------------------------------------------------------------------------
|
|
1047
1313
|
|
|
1314
|
+
private static errorMessage(err: unknown): string {
|
|
1315
|
+
return err instanceof Error ? err.message : String(err);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1048
1318
|
private requireActivePath(): ActivePath {
|
|
1049
1319
|
if (this.activePath === null) {
|
|
1050
1320
|
throw new Error("No active path.");
|
|
@@ -1143,15 +1413,36 @@ export class PathEngine {
|
|
|
1143
1413
|
active.currentStepIndex < active.definition.steps.length
|
|
1144
1414
|
) {
|
|
1145
1415
|
const item = active.definition.steps[active.currentStepIndex];
|
|
1146
|
-
if (!item.shouldSkip)
|
|
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
|
+
}
|
|
1147
1422
|
const ctx: PathStepContext = {
|
|
1148
1423
|
pathId: active.definition.id,
|
|
1149
1424
|
stepId: item.id,
|
|
1150
1425
|
data: { ...active.data },
|
|
1151
1426
|
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1152
1427
|
};
|
|
1153
|
-
const
|
|
1154
|
-
if (
|
|
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);
|
|
1155
1446
|
active.currentStepIndex += direction;
|
|
1156
1447
|
}
|
|
1157
1448
|
}
|
|
@@ -1159,6 +1450,7 @@ export class PathEngine {
|
|
|
1159
1450
|
private async enterCurrentStep(): Promise<Partial<PathData> | void> {
|
|
1160
1451
|
// Each step starts fresh — errors are not shown until the user attempts to proceed.
|
|
1161
1452
|
this._hasAttemptedNext = false;
|
|
1453
|
+
this._blockingError = null;
|
|
1162
1454
|
const active = this.activePath;
|
|
1163
1455
|
if (!active) return;
|
|
1164
1456
|
|
|
@@ -1205,7 +1497,7 @@ export class PathEngine {
|
|
|
1205
1497
|
private async canMoveNext(
|
|
1206
1498
|
active: ActivePath,
|
|
1207
1499
|
step: PathStep
|
|
1208
|
-
): Promise<boolean> {
|
|
1500
|
+
): Promise<{ allowed: boolean; reason: string | null }> {
|
|
1209
1501
|
if (step.canMoveNext) {
|
|
1210
1502
|
const ctx: PathStepContext = {
|
|
1211
1503
|
pathId: active.definition.id,
|
|
@@ -1213,26 +1505,34 @@ export class PathEngine {
|
|
|
1213
1505
|
data: { ...active.data },
|
|
1214
1506
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
1215
1507
|
};
|
|
1216
|
-
|
|
1508
|
+
const result = await step.canMoveNext(ctx);
|
|
1509
|
+
return PathEngine.normaliseGuardResult(result);
|
|
1217
1510
|
}
|
|
1218
1511
|
if (step.fieldErrors) {
|
|
1219
|
-
|
|
1512
|
+
const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
1513
|
+
return { allowed, reason: null };
|
|
1220
1514
|
}
|
|
1221
|
-
return true;
|
|
1515
|
+
return { allowed: true, reason: null };
|
|
1222
1516
|
}
|
|
1223
1517
|
|
|
1224
1518
|
private async canMovePrevious(
|
|
1225
1519
|
active: ActivePath,
|
|
1226
1520
|
step: PathStep
|
|
1227
|
-
): Promise<boolean> {
|
|
1228
|
-
if (!step.canMovePrevious) return true;
|
|
1521
|
+
): Promise<{ allowed: boolean; reason: string | null }> {
|
|
1522
|
+
if (!step.canMovePrevious) return { allowed: true, reason: null };
|
|
1229
1523
|
const ctx: PathStepContext = {
|
|
1230
1524
|
pathId: active.definition.id,
|
|
1231
1525
|
stepId: step.id,
|
|
1232
1526
|
data: { ...active.data },
|
|
1233
1527
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
1234
1528
|
};
|
|
1235
|
-
|
|
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 };
|
|
1236
1536
|
}
|
|
1237
1537
|
|
|
1238
1538
|
/**
|
|
@@ -1250,7 +1550,7 @@ export class PathEngine {
|
|
|
1250
1550
|
* safe default (`true`) is returned so the UI remains operable.
|
|
1251
1551
|
*/
|
|
1252
1552
|
private evaluateGuardSync(
|
|
1253
|
-
guard: ((ctx: PathStepContext) =>
|
|
1553
|
+
guard: ((ctx: PathStepContext) => GuardResult | Promise<GuardResult>) | undefined,
|
|
1254
1554
|
active: ActivePath
|
|
1255
1555
|
): boolean {
|
|
1256
1556
|
if (!guard) return true;
|
|
@@ -1263,17 +1563,20 @@ export class PathEngine {
|
|
|
1263
1563
|
};
|
|
1264
1564
|
try {
|
|
1265
1565
|
const result = guard(ctx);
|
|
1266
|
-
if (
|
|
1267
|
-
|
|
1268
|
-
|
|
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(() => {});
|
|
1269
1570
|
console.warn(
|
|
1270
1571
|
`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
1271
1572
|
`Guards in snapshots must be synchronous. ` +
|
|
1272
1573
|
`Returning true (optimistic) as default. ` +
|
|
1273
1574
|
`The async guard will still be enforced during actual navigation.`
|
|
1274
1575
|
);
|
|
1576
|
+
return true;
|
|
1275
1577
|
}
|
|
1276
|
-
|
|
1578
|
+
// { allowed: false, reason? } object returned synchronously
|
|
1579
|
+
return false;
|
|
1277
1580
|
} catch (err) {
|
|
1278
1581
|
console.warn(
|
|
1279
1582
|
`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|