@daltonr/pathwrite-core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type PathData = Record<string, unknown>;
2
2
 
3
3
  /**
4
- * The return type of a `fieldMessages` hook. Each key is a field ID; the value
4
+ * The return type of a `fieldErrors` hook. Each key is a field ID; the value
5
5
  * is an error string, or `undefined` / omitted to indicate no error for that field.
6
6
  *
7
7
  * Use `"_"` as a key for form-level errors that don't belong to a specific field:
@@ -18,12 +18,16 @@ export interface SerializedPathState {
18
18
  data: PathData;
19
19
  visitedStepIds: string[];
20
20
  subPathMeta?: Record<string, unknown>;
21
+ stepEntryData?: PathData;
22
+ stepEnteredAt?: number;
21
23
  pathStack: Array<{
22
24
  pathId: string;
23
25
  currentStepIndex: number;
24
26
  data: PathData;
25
27
  visitedStepIds: string[];
26
28
  subPathMeta?: Record<string, unknown>;
29
+ stepEntryData?: PathData;
30
+ stepEnteredAt?: number;
27
31
  }>;
28
32
  _isNavigating: boolean;
29
33
  }
@@ -31,9 +35,9 @@ export interface SerializedPathState {
31
35
  /**
32
36
  * The interface every path state store must implement.
33
37
  *
34
- * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
38
+ * `HttpStore` from `@daltonr/pathwrite-store` is the reference
35
39
  * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
36
- * implements this interface and works with `httpPersistence` and
40
+ * implements this interface and works with `persistence` and
37
41
  * `restoreOrStart` without any other changes.
38
42
  */
39
43
  export interface PathStore {
@@ -56,6 +60,46 @@ export interface PathStepContext<TData extends PathData = PathData> {
56
60
  readonly isFirstEntry: boolean;
57
61
  }
58
62
 
63
+ /**
64
+ * A conditional step selection placed in a path's `steps` array in place of a
65
+ * single `PathStep`. When the engine reaches a `StepChoice` it calls `select`
66
+ * to decide which of the bundled `steps` to activate. The chosen step is then
67
+ * treated exactly like any other step — its hooks, guards, and validation all
68
+ * apply normally.
69
+ *
70
+ * `StepChoice` has its own `id` (used for progress tracking and `goToStep`)
71
+ * while `formId` on the snapshot exposes which inner step was selected, so the
72
+ * UI can render the right component.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * {
77
+ * id: "contact-details",
78
+ * select: ({ data }) => data.accountType === "company" ? "company" : "individual",
79
+ * steps: [
80
+ * {
81
+ * id: "individual",
82
+ * fieldErrors: ({ data }) => ({ name: !data.name ? "Required." : undefined }),
83
+ * },
84
+ * {
85
+ * id: "company",
86
+ * fieldErrors: ({ data }) => ({ companyName: !data.companyName ? "Required." : undefined }),
87
+ * },
88
+ * ],
89
+ * }
90
+ * ```
91
+ */
92
+ export interface StepChoice<TData extends PathData = PathData> {
93
+ id: string;
94
+ title?: string;
95
+ meta?: Record<string, unknown>;
96
+ /** Called on step entry. Return the `id` of the step to activate. Throws if the returned id is not found in `steps`. */
97
+ select: (ctx: PathStepContext<TData>) => string;
98
+ steps: PathStep<TData>[];
99
+ /** When `true`, the engine skips this choice slot entirely (same semantics as `PathStep.shouldSkip`). */
100
+ shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
101
+ }
102
+
59
103
  export interface PathStep<TData extends PathData = PathData> {
60
104
  id: string;
61
105
  title?: string;
@@ -69,7 +113,7 @@ export interface PathStep<TData extends PathData = PathData> {
69
113
  * by field name) so consumers do not need to duplicate validation logic in
70
114
  * the template. Return `undefined` for a field to indicate no error.
71
115
  *
72
- * When `fieldMessages` is provided and `canMoveNext` is **not**, the engine
116
+ * When `fieldErrors` is provided and `canMoveNext` is **not**, the engine
73
117
  * automatically derives `canMoveNext` as `true` when all values are `undefined`
74
118
  * (i.e. no messages), eliminating the need to express the same logic twice.
75
119
  *
@@ -77,13 +121,29 @@ export interface PathStep<TData extends PathData = PathData> {
77
121
  *
78
122
  * @example
79
123
  * ```typescript
80
- * fieldMessages: ({ data }) => ({
124
+ * fieldErrors: ({ data }) => ({
81
125
  * name: !data.name?.trim() ? "Required." : undefined,
82
126
  * email: !isValidEmail(data.email) ? "Invalid email address." : undefined,
83
127
  * })
84
128
  * ```
85
129
  */
86
- fieldMessages?: (ctx: PathStepContext<TData>) => FieldErrors;
130
+ fieldErrors?: (ctx: PathStepContext<TData>) => FieldErrors;
131
+ /**
132
+ * Returns a map of field ID → warning message for non-blocking advisories.
133
+ * Same shape as `fieldErrors`, but warnings never affect `canMoveNext` —
134
+ * they are purely informational. Shells render them in amber/yellow instead
135
+ * of red.
136
+ *
137
+ * Evaluated synchronously on every snapshot; async functions default to `{}`.
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * fieldWarnings: ({ data }) => ({
142
+ * email: looksLikeTypo(data.email) ? "Did you mean gmail.com?" : undefined,
143
+ * })
144
+ * ```
145
+ */
146
+ fieldWarnings?: (ctx: PathStepContext<TData>) => FieldErrors;
87
147
  onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
88
148
  onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
89
149
  /**
@@ -117,7 +177,20 @@ export interface PathStep<TData extends PathData = PathData> {
117
177
  export interface PathDefinition<TData extends PathData = PathData> {
118
178
  id: string;
119
179
  title?: string;
120
- steps: PathStep<TData>[];
180
+ steps: (PathStep<TData> | StepChoice<TData>)[];
181
+ /**
182
+ * Optional callback invoked when this path completes (i.e. the user
183
+ * reaches the end of the last step). Receives the final path data.
184
+ * Only called for top-level paths — sub-path completion is handled by
185
+ * the parent step's `onSubPathComplete` hook.
186
+ */
187
+ onComplete?: (data: TData) => void | Promise<void>;
188
+ /**
189
+ * Optional callback invoked when this path is cancelled. Receives the
190
+ * path data at the time of cancellation. Only called for top-level paths —
191
+ * sub-path cancellation is handled by the parent step's `onSubPathCancel` hook.
192
+ */
193
+ onCancel?: (data: TData) => void | Promise<void>;
121
194
  }
122
195
 
123
196
  export type StepStatus = "completed" | "current" | "upcoming";
@@ -161,6 +234,12 @@ export interface PathSnapshot<TData extends PathData = PathData> {
161
234
  stepId: string;
162
235
  stepTitle?: string;
163
236
  stepMeta?: Record<string, unknown>;
237
+ /**
238
+ * The `id` of the selected inner `PathStep` when the current position in
239
+ * the path is a `StepChoice`. `undefined` for ordinary steps.
240
+ * Use this to decide which form component to render.
241
+ */
242
+ formId?: string;
164
243
  stepIndex: number;
165
244
  stepCount: number;
166
245
  progress: number;
@@ -176,7 +255,7 @@ export interface PathSnapshot<TData extends PathData = PathData> {
176
255
  rootProgress?: RootProgress;
177
256
  /** True while an async guard or hook is executing. Use to disable navigation controls. */
178
257
  isNavigating: boolean;
179
- /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldMessages` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
258
+ /** 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. */
180
259
  canMoveNext: boolean;
181
260
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
182
261
  canMovePrevious: boolean;
@@ -189,22 +268,58 @@ export interface PathSnapshot<TData extends PathData = PathData> {
189
268
  * render and only appear after the user has attempted to proceed:
190
269
  *
191
270
  * ```svelte
192
- * {#if snapshot.hasAttemptedNext && snapshot.fieldMessages.email}
193
- * <span class="error">{snapshot.fieldMessages.email}</span>
271
+ * {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
272
+ * <span class="error">{snapshot.fieldErrors.email}</span>
194
273
  * {/if}
195
274
  * ```
196
275
  *
197
- * The shell itself uses this flag to gate its own automatic `fieldMessages`
276
+ * The shell itself uses this flag to gate its own automatic `fieldErrors`
198
277
  * summary rendering — errors are never shown before the first Next attempt.
199
278
  */
200
279
  hasAttemptedNext: boolean;
280
+ /**
281
+ * True if any data has changed since entering this step. Automatically computed
282
+ * by comparing the current data to the snapshot taken on step entry. Resets to
283
+ * `false` when navigating to a new step or calling `resetStep()`.
284
+ *
285
+ * Useful for "unsaved changes" warnings, disabling Save buttons until changes
286
+ * are made, or styling forms to indicate modifications.
287
+ *
288
+ * ```typescript
289
+ * {#if snapshot.isDirty}
290
+ * <span class="warning">You have unsaved changes</span>
291
+ * {/if}
292
+ * ```
293
+ */
294
+ isDirty: boolean;
295
+ /**
296
+ * Timestamp (from `Date.now()`) captured when the current step was entered.
297
+ * Useful for analytics, timeout warnings, or computing how long a user has
298
+ * been on a step.
299
+ *
300
+ * ```typescript
301
+ * const durationMs = Date.now() - snapshot.stepEnteredAt;
302
+ * const durationSec = Math.floor(durationMs / 1000);
303
+ * ```
304
+ *
305
+ * Resets to a new timestamp each time the step is entered (including when
306
+ * navigating back to a previously visited step).
307
+ */
308
+ stepEnteredAt: number;
201
309
  /**
202
310
  * Field-keyed validation messages for the current step. Empty object when there are none.
203
- * Use in step templates to render inline per-field errors: `snapshot.fieldMessages['email']`.
311
+ * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
204
312
  * The shell also renders these automatically in a labeled summary box.
205
313
  * Use `"_"` as a key for form-level (non-field-specific) errors.
206
314
  */
207
- fieldMessages: Record<string, string>;
315
+ fieldErrors: Record<string, string>;
316
+ /**
317
+ * Field-keyed warning messages for the current step. Empty object when there are none.
318
+ * Same shape as `fieldErrors` but purely informational — warnings never block navigation.
319
+ * Shells render these in amber/yellow instead of red.
320
+ * Use `"_"` as a key for form-level (non-field-specific) warnings.
321
+ */
322
+ fieldWarnings: Record<string, string>;
208
323
  data: TData;
209
324
  }
210
325
 
@@ -218,6 +333,7 @@ export type StateChangeCause =
218
333
  | "goToStep"
219
334
  | "goToStepChecked"
220
335
  | "setData"
336
+ | "resetStep"
221
337
  | "cancel"
222
338
  | "restart";
223
339
 
@@ -316,6 +432,16 @@ interface ActivePath {
316
432
  data: PathData;
317
433
  visitedStepIds: Set<string>;
318
434
  subPathMeta?: Record<string, unknown>;
435
+ /** Snapshot of data taken when the current step was entered. Used by resetStep(). */
436
+ stepEntryData: PathData;
437
+ /** Timestamp (Date.now()) captured when the current step was entered. */
438
+ stepEnteredAt: number;
439
+ /** The selected inner step when the current slot is a StepChoice. Cached on entry. */
440
+ resolvedChoiceStep?: PathStep;
441
+ }
442
+
443
+ function isStepChoice(item: PathStep | StepChoice): item is StepChoice {
444
+ return "select" in item && "steps" in item;
319
445
  }
320
446
 
321
447
  export class PathEngine {
@@ -325,6 +451,9 @@ export class PathEngine {
325
451
  private _isNavigating = false;
326
452
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
327
453
  private _hasAttemptedNext = false;
454
+ /** The path and initial data from the most recent top-level start() call. Used by restart(). */
455
+ private _rootPath: PathDefinition<any> | null = null;
456
+ private _rootInitialData: PathData = {};
328
457
 
329
458
  constructor(options?: PathEngineOptions) {
330
459
  if (options?.observers) {
@@ -374,7 +503,9 @@ export class PathEngine {
374
503
  currentStepIndex: stackItem.currentStepIndex,
375
504
  data: { ...stackItem.data },
376
505
  visitedStepIds: new Set(stackItem.visitedStepIds),
377
- subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
506
+ subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
507
+ stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
508
+ stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
378
509
  });
379
510
  }
380
511
 
@@ -393,11 +524,20 @@ export class PathEngine {
393
524
  visitedStepIds: new Set(state.visitedStepIds),
394
525
  // Active path's subPathMeta is not serialized (it's transient metadata
395
526
  // from the parent when this path was started). On restore, it's undefined.
396
- subPathMeta: undefined
527
+ subPathMeta: undefined,
528
+ stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
529
+ stepEnteredAt: state.stepEnteredAt ?? Date.now()
397
530
  };
398
531
 
399
532
  engine._isNavigating = state._isNavigating;
400
533
 
534
+ // Re-derive the selected inner step for any StepChoice slots (not serialized —
535
+ // always recomputed from current data on restore).
536
+ for (const stackItem of engine.pathStack) {
537
+ engine.cacheResolvedChoiceStep(stackItem);
538
+ }
539
+ engine.cacheResolvedChoiceStep(engine.activePath);
540
+
401
541
  return engine;
402
542
  }
403
543
 
@@ -412,27 +552,30 @@ export class PathEngine {
412
552
 
413
553
  public start(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
414
554
  this.assertPathHasSteps(path);
555
+ this._rootPath = path;
556
+ this._rootInitialData = initialData;
415
557
  return this._startAsync(path, initialData);
416
558
  }
417
559
 
418
560
  /**
419
561
  * Tears down any active path (and the entire sub-path stack) without firing
420
- * lifecycle hooks or emitting `cancelled`, then immediately starts the given
421
- * path from scratch.
562
+ * lifecycle hooks or emitting `cancelled`, then immediately restarts the same
563
+ * path with the same initial data that was passed to the original `start()` call.
422
564
  *
423
565
  * Safe to call at any time — whether a path is running, already completed,
424
566
  * or has never been started. Use this to implement a "Start over" button or
425
567
  * to retry a path after completion without remounting the host component.
426
568
  *
427
- * @param path The path definition to (re)start.
428
- * @param initialData Data to seed the fresh path with. Defaults to `{}`.
569
+ * @throws If `restart()` is called before `start()` has ever been called.
429
570
  */
430
- public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
431
- this.assertPathHasSteps(path);
571
+ public restart(): Promise<void> {
572
+ if (!this._rootPath) {
573
+ throw new Error("Cannot restart: engine has not been started. Call start() first.");
574
+ }
432
575
  this._isNavigating = false;
433
576
  this.activePath = null;
434
577
  this.pathStack.length = 0;
435
- return this._startAsync(path, initialData);
578
+ return this._startAsync(this._rootPath, { ...this._rootInitialData });
436
579
  }
437
580
 
438
581
  /**
@@ -465,9 +608,9 @@ export class PathEngine {
465
608
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
466
609
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
467
610
  * API consistency. */
468
- public cancel(): Promise<void> {
611
+ public async cancel(): Promise<void> {
469
612
  const active = this.requireActivePath();
470
- if (this._isNavigating) return Promise.resolve();
613
+ if (this._isNavigating) return;
471
614
 
472
615
  const cancelledPathId = active.definition.id;
473
616
  const cancelledData = { ...active.data };
@@ -479,9 +622,12 @@ export class PathEngine {
479
622
  return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
480
623
  }
481
624
 
625
+ // Top-level path cancelled — call onCancel hook if defined
482
626
  this.activePath = null;
483
627
  this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
484
- return Promise.resolve();
628
+ if (active.definition.onCancel) {
629
+ await active.definition.onCancel(cancelledData);
630
+ }
485
631
  }
486
632
 
487
633
  public setData(key: string, value: unknown): Promise<void> {
@@ -491,6 +637,20 @@ export class PathEngine {
491
637
  return Promise.resolve();
492
638
  }
493
639
 
640
+ /**
641
+ * Resets the current step's data to what it was when the step was entered.
642
+ * Useful for "Clear" or "Reset" buttons that undo changes within a step.
643
+ * Emits a `stateChanged` event with cause `"resetStep"`.
644
+ * Throws if no path is active.
645
+ */
646
+ public resetStep(): Promise<void> {
647
+ const active = this.requireActivePath();
648
+ // Restore data from the snapshot taken when this step was entered
649
+ active.data = { ...active.stepEntryData };
650
+ this.emitStateChanged("resetStep");
651
+ return Promise.resolve();
652
+ }
653
+
494
654
  /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
495
655
  public goToStep(stepId: string): Promise<void> {
496
656
  const active = this.requireActivePath();
@@ -527,7 +687,8 @@ export class PathEngine {
527
687
  }
528
688
 
529
689
  const active = this.activePath;
530
- const step = this.getCurrentStep(active);
690
+ const item = this.getCurrentItem(active);
691
+ const effectiveStep = this.getEffectiveStep(active);
531
692
  const { steps } = active.definition;
532
693
  const stepCount = steps.length;
533
694
 
@@ -555,9 +716,10 @@ export class PathEngine {
555
716
 
556
717
  return {
557
718
  pathId: active.definition.id,
558
- stepId: step.id,
559
- stepTitle: step.title,
560
- stepMeta: step.meta,
719
+ stepId: item.id,
720
+ stepTitle: effectiveStep.title ?? item.title,
721
+ stepMeta: effectiveStep.meta ?? item.meta,
722
+ formId: isStepChoice(item) ? effectiveStep.id : undefined,
561
723
  stepIndex: active.currentStepIndex,
562
724
  stepCount,
563
725
  progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
@@ -577,16 +739,19 @@ export class PathEngine {
577
739
  rootProgress,
578
740
  isNavigating: this._isNavigating,
579
741
  hasAttemptedNext: this._hasAttemptedNext,
580
- canMoveNext: this.evaluateCanMoveNextSync(step, active),
581
- canMovePrevious: this.evaluateGuardSync(step.canMovePrevious, active),
582
- fieldMessages: this.evaluateFieldMessagesSync(step.fieldMessages, active),
742
+ canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
743
+ canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
744
+ fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
745
+ fieldWarnings: this.evaluateFieldMessagesSync(effectiveStep.fieldWarnings, active),
746
+ isDirty: this.computeIsDirty(active),
747
+ stepEnteredAt: active.stepEnteredAt,
583
748
  data: { ...active.data }
584
749
  };
585
750
  }
586
751
 
587
752
  /**
588
753
  * Exports the current engine state as a plain JSON-serializable object.
589
- * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
754
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
590
755
  * persist and restore wizard progress.
591
756
  *
592
757
  * Returns `null` if no path is active.
@@ -608,12 +773,16 @@ export class PathEngine {
608
773
  currentStepIndex: active.currentStepIndex,
609
774
  data: { ...active.data },
610
775
  visitedStepIds: Array.from(active.visitedStepIds),
776
+ stepEntryData: { ...active.stepEntryData },
777
+ stepEnteredAt: active.stepEnteredAt,
611
778
  pathStack: this.pathStack.map((p) => ({
612
779
  pathId: p.definition.id,
613
780
  currentStepIndex: p.currentStepIndex,
614
781
  data: { ...p.data },
615
782
  visitedStepIds: Array.from(p.visitedStepIds),
616
- subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
783
+ subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
784
+ stepEntryData: { ...p.stepEntryData },
785
+ stepEnteredAt: p.stepEnteredAt
617
786
  })),
618
787
  _isNavigating: this._isNavigating
619
788
  };
@@ -640,7 +809,9 @@ export class PathEngine {
640
809
  currentStepIndex: 0,
641
810
  data: { ...initialData },
642
811
  visitedStepIds: new Set(),
643
- subPathMeta: undefined
812
+ subPathMeta: undefined,
813
+ stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
814
+ stepEnteredAt: 0 // Will be set in enterCurrentStep
644
815
  };
645
816
 
646
817
  this._isNavigating = true;
@@ -676,7 +847,7 @@ export class PathEngine {
676
847
  this.emitStateChanged("next");
677
848
 
678
849
  try {
679
- const step = this.getCurrentStep(active);
850
+ const step = this.getEffectiveStep(active);
680
851
 
681
852
  if (await this.canMoveNext(active, step)) {
682
853
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -713,7 +884,7 @@ export class PathEngine {
713
884
  this.emitStateChanged("previous");
714
885
 
715
886
  try {
716
- const step = this.getCurrentStep(active);
887
+ const step = this.getEffectiveStep(active);
717
888
 
718
889
  if (await this.canMovePrevious(active, step)) {
719
890
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -745,7 +916,7 @@ export class PathEngine {
745
916
  this.emitStateChanged("goToStep");
746
917
 
747
918
  try {
748
- const currentStep = this.getCurrentStep(active);
919
+ const currentStep = this.getEffectiveStep(active);
749
920
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
750
921
 
751
922
  active.currentStepIndex = targetIndex;
@@ -767,7 +938,7 @@ export class PathEngine {
767
938
  this.emitStateChanged("goToStepChecked");
768
939
 
769
940
  try {
770
- const currentStep = this.getCurrentStep(active);
941
+ const currentStep = this.getEffectiveStep(active);
771
942
  const goingForward = targetIndex > active.currentStepIndex;
772
943
  const allowed = goingForward
773
944
  ? await this.canMoveNext(active, currentStep)
@@ -805,13 +976,14 @@ export class PathEngine {
805
976
  const parent = this.activePath;
806
977
 
807
978
  if (parent) {
808
- const parentStep = this.getCurrentStep(parent);
979
+ const parentItem = this.getCurrentItem(parent);
980
+ const parentStep = this.getEffectiveStep(parent);
809
981
  if (parentStep.onSubPathCancel) {
810
982
  const ctx: PathStepContext = {
811
983
  pathId: parent.definition.id,
812
- stepId: parentStep.id,
984
+ stepId: parentItem.id,
813
985
  data: { ...parent.data },
814
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
986
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
815
987
  };
816
988
  this.applyPatch(
817
989
  await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
@@ -838,14 +1010,15 @@ export class PathEngine {
838
1010
  // The meta is stored on the parent, not the sub-path
839
1011
  const finishedMeta = parent.subPathMeta;
840
1012
  this.activePath = parent;
841
- const parentStep = this.getCurrentStep(parent);
1013
+ const parentItem = this.getCurrentItem(parent);
1014
+ const parentStep = this.getEffectiveStep(parent);
842
1015
 
843
1016
  if (parentStep.onSubPathComplete) {
844
1017
  const ctx: PathStepContext = {
845
1018
  pathId: parent.definition.id,
846
- stepId: parentStep.id,
1019
+ stepId: parentItem.id,
847
1020
  data: { ...parent.data },
848
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
1021
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
849
1022
  };
850
1023
  this.applyPatch(
851
1024
  await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
@@ -859,8 +1032,12 @@ export class PathEngine {
859
1032
  snapshot: this.snapshot()!
860
1033
  });
861
1034
  } else {
1035
+ // Top-level path completed — call onComplete hook if defined
862
1036
  this.activePath = null;
863
1037
  this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1038
+ if (finished.definition.onComplete) {
1039
+ await finished.definition.onComplete(finishedData);
1040
+ }
864
1041
  }
865
1042
  }
866
1043
 
@@ -891,10 +1068,63 @@ export class PathEngine {
891
1068
  this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
892
1069
  }
893
1070
 
894
- private getCurrentStep(active: ActivePath): PathStep {
1071
+ /** Returns the raw item at the current index — either a PathStep or a StepChoice. */
1072
+ private getCurrentItem(active: ActivePath): PathStep | StepChoice {
895
1073
  return active.definition.steps[active.currentStepIndex];
896
1074
  }
897
1075
 
1076
+ /**
1077
+ * Calls `StepChoice.select` and caches the chosen inner step in
1078
+ * `active.resolvedChoiceStep`. Clears the cache when the current item is a
1079
+ * plain `PathStep`. Throws if `select` returns an id not present in `steps`.
1080
+ */
1081
+ private cacheResolvedChoiceStep(active: ActivePath): void {
1082
+ const item = this.getCurrentItem(active);
1083
+ if (!isStepChoice(item)) {
1084
+ active.resolvedChoiceStep = undefined;
1085
+ return;
1086
+ }
1087
+ const ctx: PathStepContext = {
1088
+ pathId: active.definition.id,
1089
+ stepId: item.id,
1090
+ data: { ...active.data },
1091
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1092
+ };
1093
+ let selectedId: string;
1094
+ try {
1095
+ selectedId = item.select(ctx);
1096
+ } catch (err) {
1097
+ throw new Error(
1098
+ `[pathwrite] StepChoice "${item.id}".select() threw an error: ${err}`
1099
+ );
1100
+ }
1101
+ const found = item.steps.find((s) => s.id === selectedId);
1102
+ if (!found) {
1103
+ throw new Error(
1104
+ `[pathwrite] StepChoice "${item.id}".select() returned "${selectedId}" ` +
1105
+ `but no step with that id exists in its steps array.`
1106
+ );
1107
+ }
1108
+ active.resolvedChoiceStep = found;
1109
+ }
1110
+
1111
+ /**
1112
+ * Returns the effective `PathStep` for the current position. When the
1113
+ * current item is a `StepChoice`, returns the cached resolved inner step.
1114
+ * When it is a plain `PathStep`, returns it directly.
1115
+ */
1116
+ private getEffectiveStep(active: ActivePath): PathStep {
1117
+ if (active.resolvedChoiceStep) return active.resolvedChoiceStep;
1118
+ const item = this.getCurrentItem(active);
1119
+ if (isStepChoice(item)) {
1120
+ // resolvedChoiceStep should always be set after enterCurrentStep; this
1121
+ // branch is a defensive fallback (e.g. during fromState restore).
1122
+ this.cacheResolvedChoiceStep(active);
1123
+ return active.resolvedChoiceStep!;
1124
+ }
1125
+ return item;
1126
+ }
1127
+
898
1128
  private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
899
1129
  if (patch && typeof patch === "object") {
900
1130
  const active = this.activePath;
@@ -912,15 +1142,15 @@ export class PathEngine {
912
1142
  active.currentStepIndex >= 0 &&
913
1143
  active.currentStepIndex < active.definition.steps.length
914
1144
  ) {
915
- const step = active.definition.steps[active.currentStepIndex];
916
- if (!step.shouldSkip) break;
1145
+ const item = active.definition.steps[active.currentStepIndex];
1146
+ if (!item.shouldSkip) break;
917
1147
  const ctx: PathStepContext = {
918
1148
  pathId: active.definition.id,
919
- stepId: step.id,
1149
+ stepId: item.id,
920
1150
  data: { ...active.data },
921
- isFirstEntry: !active.visitedStepIds.has(step.id)
1151
+ isFirstEntry: !active.visitedStepIds.has(item.id)
922
1152
  };
923
- const skip = await step.shouldSkip(ctx);
1153
+ const skip = await item.shouldSkip(ctx);
924
1154
  if (!skip) break;
925
1155
  active.currentStepIndex += direction;
926
1156
  }
@@ -931,19 +1161,31 @@ export class PathEngine {
931
1161
  this._hasAttemptedNext = false;
932
1162
  const active = this.activePath;
933
1163
  if (!active) return;
934
- const step = this.getCurrentStep(active);
935
- const isFirstEntry = !active.visitedStepIds.has(step.id);
1164
+
1165
+ // Save a snapshot of the data as it was when entering this step (for resetStep)
1166
+ active.stepEntryData = { ...active.data };
1167
+
1168
+ // Capture the timestamp when entering this step (for analytics, timeout warnings)
1169
+ active.stepEnteredAt = Date.now();
1170
+
1171
+ const item = this.getCurrentItem(active);
1172
+
1173
+ // Resolve the inner step when this slot is a StepChoice
1174
+ this.cacheResolvedChoiceStep(active);
1175
+
1176
+ const effectiveStep = this.getEffectiveStep(active);
1177
+ const isFirstEntry = !active.visitedStepIds.has(item.id);
936
1178
  // Mark as visited before calling onEnter so re-entrant calls see the
937
1179
  // correct isFirstEntry value.
938
- active.visitedStepIds.add(step.id);
939
- if (!step.onEnter) return;
1180
+ active.visitedStepIds.add(item.id);
1181
+ if (!effectiveStep.onEnter) return;
940
1182
  const ctx: PathStepContext = {
941
1183
  pathId: active.definition.id,
942
- stepId: step.id,
1184
+ stepId: item.id,
943
1185
  data: { ...active.data },
944
1186
  isFirstEntry
945
1187
  };
946
- return step.onEnter(ctx);
1188
+ return effectiveStep.onEnter(ctx);
947
1189
  }
948
1190
 
949
1191
  private async leaveCurrentStep(
@@ -973,8 +1215,8 @@ export class PathEngine {
973
1215
  };
974
1216
  return step.canMoveNext(ctx);
975
1217
  }
976
- if (step.fieldMessages) {
977
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldMessages, active)).length === 0;
1218
+ if (step.fieldErrors) {
1219
+ return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
978
1220
  }
979
1221
  return true;
980
1222
  }
@@ -1012,12 +1254,12 @@ export class PathEngine {
1012
1254
  active: ActivePath
1013
1255
  ): boolean {
1014
1256
  if (!guard) return true;
1015
- const step = this.getCurrentStep(active);
1257
+ const item = this.getCurrentItem(active);
1016
1258
  const ctx: PathStepContext = {
1017
1259
  pathId: active.definition.id,
1018
- stepId: step.id,
1260
+ stepId: item.id,
1019
1261
  data: { ...active.data },
1020
- isFirstEntry: !active.visitedStepIds.has(step.id)
1262
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1021
1263
  };
1022
1264
  try {
1023
1265
  const result = guard(ctx);
@@ -1025,7 +1267,7 @@ export class PathEngine {
1025
1267
  // Async guard detected - warn and return optimistic default
1026
1268
  if (result && typeof result.then === "function") {
1027
1269
  console.warn(
1028
- `[pathwrite] Async guard detected on step "${step.id}". ` +
1270
+ `[pathwrite] Async guard detected on step "${item.id}". ` +
1029
1271
  `Guards in snapshots must be synchronous. ` +
1030
1272
  `Returning true (optimistic) as default. ` +
1031
1273
  `The async guard will still be enforced during actual navigation.`
@@ -1034,7 +1276,7 @@ export class PathEngine {
1034
1276
  return true;
1035
1277
  } catch (err) {
1036
1278
  console.warn(
1037
- `[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
1279
+ `[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
1038
1280
  `Returning true (allow navigation) as a safe default. ` +
1039
1281
  `Note: guards are evaluated before onEnter runs on first entry — ` +
1040
1282
  `ensure guards handle missing/undefined data gracefully.`,
@@ -1047,24 +1289,24 @@ export class PathEngine {
1047
1289
  /**
1048
1290
  * Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
1049
1291
  * When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
1050
- * When absent but `fieldMessages` is defined, auto-derives: `true` iff no messages.
1292
+ * When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
1051
1293
  * When neither is defined, returns `true`.
1052
1294
  */
1053
1295
  private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
1054
1296
  if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
1055
- if (step.fieldMessages) {
1056
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldMessages, active)).length === 0;
1297
+ if (step.fieldErrors) {
1298
+ return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1057
1299
  }
1058
1300
  return true;
1059
1301
  }
1060
1302
 
1061
1303
  /**
1062
- * Evaluates a fieldMessages function synchronously for inclusion in the snapshot.
1304
+ * Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
1063
1305
  * If the hook is absent, returns `{}`.
1064
1306
  * If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
1065
1307
  * `undefined` values are stripped from the result — only fields with a defined message are included.
1066
1308
  *
1067
- * **Note:** Like guards, `fieldMessages` is evaluated before `onEnter` runs on first
1309
+ * **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
1068
1310
  * entry. Write it defensively so it does not throw when fields are absent.
1069
1311
  */
1070
1312
  private evaluateFieldMessagesSync(
@@ -1072,12 +1314,12 @@ export class PathEngine {
1072
1314
  active: ActivePath
1073
1315
  ): Record<string, string> {
1074
1316
  if (!fn) return {};
1075
- const step = this.getCurrentStep(active);
1317
+ const item = this.getCurrentItem(active);
1076
1318
  const ctx: PathStepContext = {
1077
1319
  pathId: active.definition.id,
1078
- stepId: step.id,
1320
+ stepId: item.id,
1079
1321
  data: { ...active.data },
1080
- isFirstEntry: !active.visitedStepIds.has(step.id)
1322
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1081
1323
  };
1082
1324
  try {
1083
1325
  const result = fn(ctx);
@@ -1092,22 +1334,45 @@ export class PathEngine {
1092
1334
  }
1093
1335
  if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
1094
1336
  console.warn(
1095
- `[pathwrite] Async fieldMessages detected on step "${step.id}". ` +
1096
- `fieldMessages must be synchronous. Returning {} as default. ` +
1337
+ `[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
1338
+ `fieldErrors must be synchronous. Returning {} as default. ` +
1097
1339
  `Use synchronous validation or move async checks to canMoveNext.`
1098
1340
  );
1099
1341
  }
1100
1342
  return {};
1101
1343
  } catch (err) {
1102
1344
  console.warn(
1103
- `[pathwrite] fieldMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
1345
+ `[pathwrite] fieldErrors on step "${item.id}" threw an error during snapshot evaluation. ` +
1104
1346
  `Returning {} as a safe default. ` +
1105
- `Note: fieldMessages is evaluated before onEnter runs on first entry — ` +
1347
+ `Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
1106
1348
  `ensure it handles missing/undefined data gracefully.`,
1107
1349
  err
1108
1350
  );
1109
1351
  return {};
1110
1352
  }
1111
1353
  }
1354
+
1355
+ /**
1356
+ * Compares the current step data to the snapshot taken when the step was entered.
1357
+ * Returns `true` if any data value has changed.
1358
+ *
1359
+ * Performs a shallow comparison — only top-level keys are checked. Nested objects
1360
+ * are compared by reference, not by deep equality.
1361
+ */
1362
+ private computeIsDirty(active: ActivePath): boolean {
1363
+ const current = active.data;
1364
+ const entry = active.stepEntryData;
1365
+
1366
+ // Get all unique keys from both objects
1367
+ const allKeys = new Set([...Object.keys(current), ...Object.keys(entry)]);
1368
+
1369
+ for (const key of allKeys) {
1370
+ if (current[key] !== entry[key]) {
1371
+ return true;
1372
+ }
1373
+ }
1374
+
1375
+ return false;
1376
+ }
1112
1377
  }
1113
1378