@daltonr/pathwrite-core 0.6.3 → 0.8.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
  }
@@ -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";
@@ -129,11 +202,44 @@ export interface StepSummary {
129
202
  status: StepStatus;
130
203
  }
131
204
 
205
+ /**
206
+ * Controls how the shell renders progress bars when a sub-path is active.
207
+ *
208
+ * | Value | Behaviour |
209
+ * |----------------|------------------------------------------------------------------------|
210
+ * | `"merged"` | Root and sub-path bars in one card (default) |
211
+ * | `"split"` | Root and sub-path bars as separate cards |
212
+ * | `"rootOnly"` | Only the root bar — sub-path bar hidden |
213
+ * | `"activeOnly"` | Only the active (sub-path) bar — root bar hidden (pre-v0.7 behaviour) |
214
+ */
215
+ export type ProgressLayout = "merged" | "split" | "rootOnly" | "activeOnly";
216
+
217
+ /**
218
+ * Summary of the root (top-level) path's progress. Present on `PathSnapshot`
219
+ * only when `nestingLevel > 0` — i.e. a sub-path is active.
220
+ *
221
+ * Shells use this to keep the top-level progress bar visible while navigating
222
+ * a sub-path, so users never lose sight of where they are in the main flow.
223
+ */
224
+ export interface RootProgress {
225
+ pathId: string;
226
+ stepIndex: number;
227
+ stepCount: number;
228
+ progress: number;
229
+ steps: StepSummary[];
230
+ }
231
+
132
232
  export interface PathSnapshot<TData extends PathData = PathData> {
133
233
  pathId: string;
134
234
  stepId: string;
135
235
  stepTitle?: string;
136
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;
137
243
  stepIndex: number;
138
244
  stepCount: number;
139
245
  progress: number;
@@ -141,9 +247,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
141
247
  isFirstStep: boolean;
142
248
  isLastStep: boolean;
143
249
  nestingLevel: number;
250
+ /**
251
+ * Progress summary of the root (top-level) path. Only present when
252
+ * `nestingLevel > 0`. Shells use this to render a persistent top-level
253
+ * progress bar above the sub-path's own progress bar.
254
+ */
255
+ rootProgress?: RootProgress;
144
256
  /** True while an async guard or hook is executing. Use to disable navigation controls. */
145
257
  isNavigating: boolean;
146
- /** 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. */
147
259
  canMoveNext: boolean;
148
260
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
149
261
  canMovePrevious: boolean;
@@ -156,22 +268,58 @@ export interface PathSnapshot<TData extends PathData = PathData> {
156
268
  * render and only appear after the user has attempted to proceed:
157
269
  *
158
270
  * ```svelte
159
- * {#if snapshot.hasAttemptedNext && snapshot.fieldMessages.email}
160
- * <span class="error">{snapshot.fieldMessages.email}</span>
271
+ * {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
272
+ * <span class="error">{snapshot.fieldErrors.email}</span>
161
273
  * {/if}
162
274
  * ```
163
275
  *
164
- * 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`
165
277
  * summary rendering — errors are never shown before the first Next attempt.
166
278
  */
167
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;
168
309
  /**
169
310
  * Field-keyed validation messages for the current step. Empty object when there are none.
170
- * 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']`.
171
312
  * The shell also renders these automatically in a labeled summary box.
172
313
  * Use `"_"` as a key for form-level (non-field-specific) errors.
173
314
  */
174
- 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>;
175
323
  data: TData;
176
324
  }
177
325
 
@@ -185,6 +333,7 @@ export type StateChangeCause =
185
333
  | "goToStep"
186
334
  | "goToStepChecked"
187
335
  | "setData"
336
+ | "resetStep"
188
337
  | "cancel"
189
338
  | "restart";
190
339
 
@@ -283,6 +432,16 @@ interface ActivePath {
283
432
  data: PathData;
284
433
  visitedStepIds: Set<string>;
285
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;
286
445
  }
287
446
 
288
447
  export class PathEngine {
@@ -341,7 +500,9 @@ export class PathEngine {
341
500
  currentStepIndex: stackItem.currentStepIndex,
342
501
  data: { ...stackItem.data },
343
502
  visitedStepIds: new Set(stackItem.visitedStepIds),
344
- subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
503
+ subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
504
+ stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
505
+ stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
345
506
  });
346
507
  }
347
508
 
@@ -360,11 +521,20 @@ export class PathEngine {
360
521
  visitedStepIds: new Set(state.visitedStepIds),
361
522
  // Active path's subPathMeta is not serialized (it's transient metadata
362
523
  // from the parent when this path was started). On restore, it's undefined.
363
- subPathMeta: undefined
524
+ subPathMeta: undefined,
525
+ stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
526
+ stepEnteredAt: state.stepEnteredAt ?? Date.now()
364
527
  };
365
528
 
366
529
  engine._isNavigating = state._isNavigating;
367
530
 
531
+ // Re-derive the selected inner step for any StepChoice slots (not serialized —
532
+ // always recomputed from current data on restore).
533
+ for (const stackItem of engine.pathStack) {
534
+ engine.cacheResolvedChoiceStep(stackItem);
535
+ }
536
+ engine.cacheResolvedChoiceStep(engine.activePath);
537
+
368
538
  return engine;
369
539
  }
370
540
 
@@ -432,9 +602,9 @@ export class PathEngine {
432
602
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
433
603
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
434
604
  * API consistency. */
435
- public cancel(): Promise<void> {
605
+ public async cancel(): Promise<void> {
436
606
  const active = this.requireActivePath();
437
- if (this._isNavigating) return Promise.resolve();
607
+ if (this._isNavigating) return;
438
608
 
439
609
  const cancelledPathId = active.definition.id;
440
610
  const cancelledData = { ...active.data };
@@ -446,9 +616,12 @@ export class PathEngine {
446
616
  return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
447
617
  }
448
618
 
619
+ // Top-level path cancelled — call onCancel hook if defined
449
620
  this.activePath = null;
450
621
  this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
451
- return Promise.resolve();
622
+ if (active.definition.onCancel) {
623
+ await active.definition.onCancel(cancelledData);
624
+ }
452
625
  }
453
626
 
454
627
  public setData(key: string, value: unknown): Promise<void> {
@@ -458,6 +631,20 @@ export class PathEngine {
458
631
  return Promise.resolve();
459
632
  }
460
633
 
634
+ /**
635
+ * Resets the current step's data to what it was when the step was entered.
636
+ * Useful for "Clear" or "Reset" buttons that undo changes within a step.
637
+ * Emits a `stateChanged` event with cause `"resetStep"`.
638
+ * Throws if no path is active.
639
+ */
640
+ public resetStep(): Promise<void> {
641
+ const active = this.requireActivePath();
642
+ // Restore data from the snapshot taken when this step was entered
643
+ active.data = { ...active.stepEntryData };
644
+ this.emitStateChanged("resetStep");
645
+ return Promise.resolve();
646
+ }
647
+
461
648
  /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
462
649
  public goToStep(stepId: string): Promise<void> {
463
650
  const active = this.requireActivePath();
@@ -494,15 +681,39 @@ export class PathEngine {
494
681
  }
495
682
 
496
683
  const active = this.activePath;
497
- const step = this.getCurrentStep(active);
684
+ const item = this.getCurrentItem(active);
685
+ const effectiveStep = this.getEffectiveStep(active);
498
686
  const { steps } = active.definition;
499
687
  const stepCount = steps.length;
500
688
 
689
+ // Build rootProgress from the bottom of the stack (the top-level path)
690
+ let rootProgress: RootProgress | undefined;
691
+ if (this.pathStack.length > 0) {
692
+ const root = this.pathStack[0];
693
+ const rootSteps = root.definition.steps;
694
+ const rootStepCount = rootSteps.length;
695
+ rootProgress = {
696
+ pathId: root.definition.id,
697
+ stepIndex: root.currentStepIndex,
698
+ stepCount: rootStepCount,
699
+ progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
700
+ steps: rootSteps.map((s, i) => ({
701
+ id: s.id,
702
+ title: s.title,
703
+ meta: s.meta,
704
+ status: i < root.currentStepIndex ? "completed" as const
705
+ : i === root.currentStepIndex ? "current" as const
706
+ : "upcoming" as const
707
+ }))
708
+ };
709
+ }
710
+
501
711
  return {
502
712
  pathId: active.definition.id,
503
- stepId: step.id,
504
- stepTitle: step.title,
505
- stepMeta: step.meta,
713
+ stepId: item.id,
714
+ stepTitle: effectiveStep.title ?? item.title,
715
+ stepMeta: effectiveStep.meta ?? item.meta,
716
+ formId: isStepChoice(item) ? effectiveStep.id : undefined,
506
717
  stepIndex: active.currentStepIndex,
507
718
  stepCount,
508
719
  progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
@@ -519,11 +730,15 @@ export class PathEngine {
519
730
  active.currentStepIndex === stepCount - 1 &&
520
731
  this.pathStack.length === 0,
521
732
  nestingLevel: this.pathStack.length,
733
+ rootProgress,
522
734
  isNavigating: this._isNavigating,
523
735
  hasAttemptedNext: this._hasAttemptedNext,
524
- canMoveNext: this.evaluateCanMoveNextSync(step, active),
525
- canMovePrevious: this.evaluateGuardSync(step.canMovePrevious, active),
526
- fieldMessages: this.evaluateFieldMessagesSync(step.fieldMessages, active),
736
+ canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
737
+ canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
738
+ fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
739
+ fieldWarnings: this.evaluateFieldMessagesSync(effectiveStep.fieldWarnings, active),
740
+ isDirty: this.computeIsDirty(active),
741
+ stepEnteredAt: active.stepEnteredAt,
527
742
  data: { ...active.data }
528
743
  };
529
744
  }
@@ -552,12 +767,16 @@ export class PathEngine {
552
767
  currentStepIndex: active.currentStepIndex,
553
768
  data: { ...active.data },
554
769
  visitedStepIds: Array.from(active.visitedStepIds),
770
+ stepEntryData: { ...active.stepEntryData },
771
+ stepEnteredAt: active.stepEnteredAt,
555
772
  pathStack: this.pathStack.map((p) => ({
556
773
  pathId: p.definition.id,
557
774
  currentStepIndex: p.currentStepIndex,
558
775
  data: { ...p.data },
559
776
  visitedStepIds: Array.from(p.visitedStepIds),
560
- subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
777
+ subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
778
+ stepEntryData: { ...p.stepEntryData },
779
+ stepEnteredAt: p.stepEnteredAt
561
780
  })),
562
781
  _isNavigating: this._isNavigating
563
782
  };
@@ -584,7 +803,9 @@ export class PathEngine {
584
803
  currentStepIndex: 0,
585
804
  data: { ...initialData },
586
805
  visitedStepIds: new Set(),
587
- subPathMeta: undefined
806
+ subPathMeta: undefined,
807
+ stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
808
+ stepEnteredAt: 0 // Will be set in enterCurrentStep
588
809
  };
589
810
 
590
811
  this._isNavigating = true;
@@ -620,7 +841,7 @@ export class PathEngine {
620
841
  this.emitStateChanged("next");
621
842
 
622
843
  try {
623
- const step = this.getCurrentStep(active);
844
+ const step = this.getEffectiveStep(active);
624
845
 
625
846
  if (await this.canMoveNext(active, step)) {
626
847
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -657,7 +878,7 @@ export class PathEngine {
657
878
  this.emitStateChanged("previous");
658
879
 
659
880
  try {
660
- const step = this.getCurrentStep(active);
881
+ const step = this.getEffectiveStep(active);
661
882
 
662
883
  if (await this.canMovePrevious(active, step)) {
663
884
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -689,7 +910,7 @@ export class PathEngine {
689
910
  this.emitStateChanged("goToStep");
690
911
 
691
912
  try {
692
- const currentStep = this.getCurrentStep(active);
913
+ const currentStep = this.getEffectiveStep(active);
693
914
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
694
915
 
695
916
  active.currentStepIndex = targetIndex;
@@ -711,7 +932,7 @@ export class PathEngine {
711
932
  this.emitStateChanged("goToStepChecked");
712
933
 
713
934
  try {
714
- const currentStep = this.getCurrentStep(active);
935
+ const currentStep = this.getEffectiveStep(active);
715
936
  const goingForward = targetIndex > active.currentStepIndex;
716
937
  const allowed = goingForward
717
938
  ? await this.canMoveNext(active, currentStep)
@@ -749,13 +970,14 @@ export class PathEngine {
749
970
  const parent = this.activePath;
750
971
 
751
972
  if (parent) {
752
- const parentStep = this.getCurrentStep(parent);
973
+ const parentItem = this.getCurrentItem(parent);
974
+ const parentStep = this.getEffectiveStep(parent);
753
975
  if (parentStep.onSubPathCancel) {
754
976
  const ctx: PathStepContext = {
755
977
  pathId: parent.definition.id,
756
- stepId: parentStep.id,
978
+ stepId: parentItem.id,
757
979
  data: { ...parent.data },
758
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
980
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
759
981
  };
760
982
  this.applyPatch(
761
983
  await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
@@ -782,14 +1004,15 @@ export class PathEngine {
782
1004
  // The meta is stored on the parent, not the sub-path
783
1005
  const finishedMeta = parent.subPathMeta;
784
1006
  this.activePath = parent;
785
- const parentStep = this.getCurrentStep(parent);
1007
+ const parentItem = this.getCurrentItem(parent);
1008
+ const parentStep = this.getEffectiveStep(parent);
786
1009
 
787
1010
  if (parentStep.onSubPathComplete) {
788
1011
  const ctx: PathStepContext = {
789
1012
  pathId: parent.definition.id,
790
- stepId: parentStep.id,
1013
+ stepId: parentItem.id,
791
1014
  data: { ...parent.data },
792
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
1015
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
793
1016
  };
794
1017
  this.applyPatch(
795
1018
  await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
@@ -803,8 +1026,12 @@ export class PathEngine {
803
1026
  snapshot: this.snapshot()!
804
1027
  });
805
1028
  } else {
1029
+ // Top-level path completed — call onComplete hook if defined
806
1030
  this.activePath = null;
807
1031
  this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1032
+ if (finished.definition.onComplete) {
1033
+ await finished.definition.onComplete(finishedData);
1034
+ }
808
1035
  }
809
1036
  }
810
1037
 
@@ -835,10 +1062,63 @@ export class PathEngine {
835
1062
  this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
836
1063
  }
837
1064
 
838
- private getCurrentStep(active: ActivePath): PathStep {
1065
+ /** Returns the raw item at the current index — either a PathStep or a StepChoice. */
1066
+ private getCurrentItem(active: ActivePath): PathStep | StepChoice {
839
1067
  return active.definition.steps[active.currentStepIndex];
840
1068
  }
841
1069
 
1070
+ /**
1071
+ * Calls `StepChoice.select` and caches the chosen inner step in
1072
+ * `active.resolvedChoiceStep`. Clears the cache when the current item is a
1073
+ * plain `PathStep`. Throws if `select` returns an id not present in `steps`.
1074
+ */
1075
+ private cacheResolvedChoiceStep(active: ActivePath): void {
1076
+ const item = this.getCurrentItem(active);
1077
+ if (!isStepChoice(item)) {
1078
+ active.resolvedChoiceStep = undefined;
1079
+ return;
1080
+ }
1081
+ const ctx: PathStepContext = {
1082
+ pathId: active.definition.id,
1083
+ stepId: item.id,
1084
+ data: { ...active.data },
1085
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1086
+ };
1087
+ let selectedId: string;
1088
+ try {
1089
+ selectedId = item.select(ctx);
1090
+ } catch (err) {
1091
+ throw new Error(
1092
+ `[pathwrite] StepChoice "${item.id}".select() threw an error: ${err}`
1093
+ );
1094
+ }
1095
+ const found = item.steps.find((s) => s.id === selectedId);
1096
+ if (!found) {
1097
+ throw new Error(
1098
+ `[pathwrite] StepChoice "${item.id}".select() returned "${selectedId}" ` +
1099
+ `but no step with that id exists in its steps array.`
1100
+ );
1101
+ }
1102
+ active.resolvedChoiceStep = found;
1103
+ }
1104
+
1105
+ /**
1106
+ * Returns the effective `PathStep` for the current position. When the
1107
+ * current item is a `StepChoice`, returns the cached resolved inner step.
1108
+ * When it is a plain `PathStep`, returns it directly.
1109
+ */
1110
+ private getEffectiveStep(active: ActivePath): PathStep {
1111
+ if (active.resolvedChoiceStep) return active.resolvedChoiceStep;
1112
+ const item = this.getCurrentItem(active);
1113
+ if (isStepChoice(item)) {
1114
+ // resolvedChoiceStep should always be set after enterCurrentStep; this
1115
+ // branch is a defensive fallback (e.g. during fromState restore).
1116
+ this.cacheResolvedChoiceStep(active);
1117
+ return active.resolvedChoiceStep!;
1118
+ }
1119
+ return item;
1120
+ }
1121
+
842
1122
  private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
843
1123
  if (patch && typeof patch === "object") {
844
1124
  const active = this.activePath;
@@ -856,15 +1136,15 @@ export class PathEngine {
856
1136
  active.currentStepIndex >= 0 &&
857
1137
  active.currentStepIndex < active.definition.steps.length
858
1138
  ) {
859
- const step = active.definition.steps[active.currentStepIndex];
860
- if (!step.shouldSkip) break;
1139
+ const item = active.definition.steps[active.currentStepIndex];
1140
+ if (!item.shouldSkip) break;
861
1141
  const ctx: PathStepContext = {
862
1142
  pathId: active.definition.id,
863
- stepId: step.id,
1143
+ stepId: item.id,
864
1144
  data: { ...active.data },
865
- isFirstEntry: !active.visitedStepIds.has(step.id)
1145
+ isFirstEntry: !active.visitedStepIds.has(item.id)
866
1146
  };
867
- const skip = await step.shouldSkip(ctx);
1147
+ const skip = await item.shouldSkip(ctx);
868
1148
  if (!skip) break;
869
1149
  active.currentStepIndex += direction;
870
1150
  }
@@ -875,19 +1155,31 @@ export class PathEngine {
875
1155
  this._hasAttemptedNext = false;
876
1156
  const active = this.activePath;
877
1157
  if (!active) return;
878
- const step = this.getCurrentStep(active);
879
- const isFirstEntry = !active.visitedStepIds.has(step.id);
1158
+
1159
+ // Save a snapshot of the data as it was when entering this step (for resetStep)
1160
+ active.stepEntryData = { ...active.data };
1161
+
1162
+ // Capture the timestamp when entering this step (for analytics, timeout warnings)
1163
+ active.stepEnteredAt = Date.now();
1164
+
1165
+ const item = this.getCurrentItem(active);
1166
+
1167
+ // Resolve the inner step when this slot is a StepChoice
1168
+ this.cacheResolvedChoiceStep(active);
1169
+
1170
+ const effectiveStep = this.getEffectiveStep(active);
1171
+ const isFirstEntry = !active.visitedStepIds.has(item.id);
880
1172
  // Mark as visited before calling onEnter so re-entrant calls see the
881
1173
  // correct isFirstEntry value.
882
- active.visitedStepIds.add(step.id);
883
- if (!step.onEnter) return;
1174
+ active.visitedStepIds.add(item.id);
1175
+ if (!effectiveStep.onEnter) return;
884
1176
  const ctx: PathStepContext = {
885
1177
  pathId: active.definition.id,
886
- stepId: step.id,
1178
+ stepId: item.id,
887
1179
  data: { ...active.data },
888
1180
  isFirstEntry
889
1181
  };
890
- return step.onEnter(ctx);
1182
+ return effectiveStep.onEnter(ctx);
891
1183
  }
892
1184
 
893
1185
  private async leaveCurrentStep(
@@ -917,8 +1209,8 @@ export class PathEngine {
917
1209
  };
918
1210
  return step.canMoveNext(ctx);
919
1211
  }
920
- if (step.fieldMessages) {
921
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldMessages, active)).length === 0;
1212
+ if (step.fieldErrors) {
1213
+ return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
922
1214
  }
923
1215
  return true;
924
1216
  }
@@ -956,12 +1248,12 @@ export class PathEngine {
956
1248
  active: ActivePath
957
1249
  ): boolean {
958
1250
  if (!guard) return true;
959
- const step = this.getCurrentStep(active);
1251
+ const item = this.getCurrentItem(active);
960
1252
  const ctx: PathStepContext = {
961
1253
  pathId: active.definition.id,
962
- stepId: step.id,
1254
+ stepId: item.id,
963
1255
  data: { ...active.data },
964
- isFirstEntry: !active.visitedStepIds.has(step.id)
1256
+ isFirstEntry: !active.visitedStepIds.has(item.id)
965
1257
  };
966
1258
  try {
967
1259
  const result = guard(ctx);
@@ -969,7 +1261,7 @@ export class PathEngine {
969
1261
  // Async guard detected - warn and return optimistic default
970
1262
  if (result && typeof result.then === "function") {
971
1263
  console.warn(
972
- `[pathwrite] Async guard detected on step "${step.id}". ` +
1264
+ `[pathwrite] Async guard detected on step "${item.id}". ` +
973
1265
  `Guards in snapshots must be synchronous. ` +
974
1266
  `Returning true (optimistic) as default. ` +
975
1267
  `The async guard will still be enforced during actual navigation.`
@@ -978,7 +1270,7 @@ export class PathEngine {
978
1270
  return true;
979
1271
  } catch (err) {
980
1272
  console.warn(
981
- `[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
1273
+ `[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
982
1274
  `Returning true (allow navigation) as a safe default. ` +
983
1275
  `Note: guards are evaluated before onEnter runs on first entry — ` +
984
1276
  `ensure guards handle missing/undefined data gracefully.`,
@@ -991,24 +1283,24 @@ export class PathEngine {
991
1283
  /**
992
1284
  * Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
993
1285
  * When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
994
- * When absent but `fieldMessages` is defined, auto-derives: `true` iff no messages.
1286
+ * When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
995
1287
  * When neither is defined, returns `true`.
996
1288
  */
997
1289
  private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
998
1290
  if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
999
- if (step.fieldMessages) {
1000
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldMessages, active)).length === 0;
1291
+ if (step.fieldErrors) {
1292
+ return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1001
1293
  }
1002
1294
  return true;
1003
1295
  }
1004
1296
 
1005
1297
  /**
1006
- * Evaluates a fieldMessages function synchronously for inclusion in the snapshot.
1298
+ * Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
1007
1299
  * If the hook is absent, returns `{}`.
1008
1300
  * If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
1009
1301
  * `undefined` values are stripped from the result — only fields with a defined message are included.
1010
1302
  *
1011
- * **Note:** Like guards, `fieldMessages` is evaluated before `onEnter` runs on first
1303
+ * **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
1012
1304
  * entry. Write it defensively so it does not throw when fields are absent.
1013
1305
  */
1014
1306
  private evaluateFieldMessagesSync(
@@ -1016,12 +1308,12 @@ export class PathEngine {
1016
1308
  active: ActivePath
1017
1309
  ): Record<string, string> {
1018
1310
  if (!fn) return {};
1019
- const step = this.getCurrentStep(active);
1311
+ const item = this.getCurrentItem(active);
1020
1312
  const ctx: PathStepContext = {
1021
1313
  pathId: active.definition.id,
1022
- stepId: step.id,
1314
+ stepId: item.id,
1023
1315
  data: { ...active.data },
1024
- isFirstEntry: !active.visitedStepIds.has(step.id)
1316
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1025
1317
  };
1026
1318
  try {
1027
1319
  const result = fn(ctx);
@@ -1036,22 +1328,45 @@ export class PathEngine {
1036
1328
  }
1037
1329
  if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
1038
1330
  console.warn(
1039
- `[pathwrite] Async fieldMessages detected on step "${step.id}". ` +
1040
- `fieldMessages must be synchronous. Returning {} as default. ` +
1331
+ `[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
1332
+ `fieldErrors must be synchronous. Returning {} as default. ` +
1041
1333
  `Use synchronous validation or move async checks to canMoveNext.`
1042
1334
  );
1043
1335
  }
1044
1336
  return {};
1045
1337
  } catch (err) {
1046
1338
  console.warn(
1047
- `[pathwrite] fieldMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
1339
+ `[pathwrite] fieldErrors on step "${item.id}" threw an error during snapshot evaluation. ` +
1048
1340
  `Returning {} as a safe default. ` +
1049
- `Note: fieldMessages is evaluated before onEnter runs on first entry — ` +
1341
+ `Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
1050
1342
  `ensure it handles missing/undefined data gracefully.`,
1051
1343
  err
1052
1344
  );
1053
1345
  return {};
1054
1346
  }
1055
1347
  }
1348
+
1349
+ /**
1350
+ * Compares the current step data to the snapshot taken when the step was entered.
1351
+ * Returns `true` if any data value has changed.
1352
+ *
1353
+ * Performs a shallow comparison — only top-level keys are checked. Nested objects
1354
+ * are compared by reference, not by deep equality.
1355
+ */
1356
+ private computeIsDirty(active: ActivePath): boolean {
1357
+ const current = active.data;
1358
+ const entry = active.stepEntryData;
1359
+
1360
+ // Get all unique keys from both objects
1361
+ const allKeys = new Set([...Object.keys(current), ...Object.keys(entry)]);
1362
+
1363
+ for (const key of allKeys) {
1364
+ if (current[key] !== entry[key]) {
1365
+ return true;
1366
+ }
1367
+ }
1368
+
1369
+ return false;
1370
+ }
1056
1371
  }
1057
1372