@daltonr/pathwrite-core 0.7.0 → 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";
@@ -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 {
@@ -374,7 +500,9 @@ export class PathEngine {
374
500
  currentStepIndex: stackItem.currentStepIndex,
375
501
  data: { ...stackItem.data },
376
502
  visitedStepIds: new Set(stackItem.visitedStepIds),
377
- 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()
378
506
  });
379
507
  }
380
508
 
@@ -393,11 +521,20 @@ export class PathEngine {
393
521
  visitedStepIds: new Set(state.visitedStepIds),
394
522
  // Active path's subPathMeta is not serialized (it's transient metadata
395
523
  // from the parent when this path was started). On restore, it's undefined.
396
- subPathMeta: undefined
524
+ subPathMeta: undefined,
525
+ stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
526
+ stepEnteredAt: state.stepEnteredAt ?? Date.now()
397
527
  };
398
528
 
399
529
  engine._isNavigating = state._isNavigating;
400
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
+
401
538
  return engine;
402
539
  }
403
540
 
@@ -465,9 +602,9 @@ export class PathEngine {
465
602
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
466
603
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
467
604
  * API consistency. */
468
- public cancel(): Promise<void> {
605
+ public async cancel(): Promise<void> {
469
606
  const active = this.requireActivePath();
470
- if (this._isNavigating) return Promise.resolve();
607
+ if (this._isNavigating) return;
471
608
 
472
609
  const cancelledPathId = active.definition.id;
473
610
  const cancelledData = { ...active.data };
@@ -479,9 +616,12 @@ export class PathEngine {
479
616
  return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
480
617
  }
481
618
 
619
+ // Top-level path cancelled — call onCancel hook if defined
482
620
  this.activePath = null;
483
621
  this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
484
- return Promise.resolve();
622
+ if (active.definition.onCancel) {
623
+ await active.definition.onCancel(cancelledData);
624
+ }
485
625
  }
486
626
 
487
627
  public setData(key: string, value: unknown): Promise<void> {
@@ -491,6 +631,20 @@ export class PathEngine {
491
631
  return Promise.resolve();
492
632
  }
493
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
+
494
648
  /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
495
649
  public goToStep(stepId: string): Promise<void> {
496
650
  const active = this.requireActivePath();
@@ -527,7 +681,8 @@ export class PathEngine {
527
681
  }
528
682
 
529
683
  const active = this.activePath;
530
- const step = this.getCurrentStep(active);
684
+ const item = this.getCurrentItem(active);
685
+ const effectiveStep = this.getEffectiveStep(active);
531
686
  const { steps } = active.definition;
532
687
  const stepCount = steps.length;
533
688
 
@@ -555,9 +710,10 @@ export class PathEngine {
555
710
 
556
711
  return {
557
712
  pathId: active.definition.id,
558
- stepId: step.id,
559
- stepTitle: step.title,
560
- 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,
561
717
  stepIndex: active.currentStepIndex,
562
718
  stepCount,
563
719
  progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
@@ -577,9 +733,12 @@ export class PathEngine {
577
733
  rootProgress,
578
734
  isNavigating: this._isNavigating,
579
735
  hasAttemptedNext: this._hasAttemptedNext,
580
- canMoveNext: this.evaluateCanMoveNextSync(step, active),
581
- canMovePrevious: this.evaluateGuardSync(step.canMovePrevious, active),
582
- 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,
583
742
  data: { ...active.data }
584
743
  };
585
744
  }
@@ -608,12 +767,16 @@ export class PathEngine {
608
767
  currentStepIndex: active.currentStepIndex,
609
768
  data: { ...active.data },
610
769
  visitedStepIds: Array.from(active.visitedStepIds),
770
+ stepEntryData: { ...active.stepEntryData },
771
+ stepEnteredAt: active.stepEnteredAt,
611
772
  pathStack: this.pathStack.map((p) => ({
612
773
  pathId: p.definition.id,
613
774
  currentStepIndex: p.currentStepIndex,
614
775
  data: { ...p.data },
615
776
  visitedStepIds: Array.from(p.visitedStepIds),
616
- subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
777
+ subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
778
+ stepEntryData: { ...p.stepEntryData },
779
+ stepEnteredAt: p.stepEnteredAt
617
780
  })),
618
781
  _isNavigating: this._isNavigating
619
782
  };
@@ -640,7 +803,9 @@ export class PathEngine {
640
803
  currentStepIndex: 0,
641
804
  data: { ...initialData },
642
805
  visitedStepIds: new Set(),
643
- subPathMeta: undefined
806
+ subPathMeta: undefined,
807
+ stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
808
+ stepEnteredAt: 0 // Will be set in enterCurrentStep
644
809
  };
645
810
 
646
811
  this._isNavigating = true;
@@ -676,7 +841,7 @@ export class PathEngine {
676
841
  this.emitStateChanged("next");
677
842
 
678
843
  try {
679
- const step = this.getCurrentStep(active);
844
+ const step = this.getEffectiveStep(active);
680
845
 
681
846
  if (await this.canMoveNext(active, step)) {
682
847
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -713,7 +878,7 @@ export class PathEngine {
713
878
  this.emitStateChanged("previous");
714
879
 
715
880
  try {
716
- const step = this.getCurrentStep(active);
881
+ const step = this.getEffectiveStep(active);
717
882
 
718
883
  if (await this.canMovePrevious(active, step)) {
719
884
  this.applyPatch(await this.leaveCurrentStep(active, step));
@@ -745,7 +910,7 @@ export class PathEngine {
745
910
  this.emitStateChanged("goToStep");
746
911
 
747
912
  try {
748
- const currentStep = this.getCurrentStep(active);
913
+ const currentStep = this.getEffectiveStep(active);
749
914
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
750
915
 
751
916
  active.currentStepIndex = targetIndex;
@@ -767,7 +932,7 @@ export class PathEngine {
767
932
  this.emitStateChanged("goToStepChecked");
768
933
 
769
934
  try {
770
- const currentStep = this.getCurrentStep(active);
935
+ const currentStep = this.getEffectiveStep(active);
771
936
  const goingForward = targetIndex > active.currentStepIndex;
772
937
  const allowed = goingForward
773
938
  ? await this.canMoveNext(active, currentStep)
@@ -805,13 +970,14 @@ export class PathEngine {
805
970
  const parent = this.activePath;
806
971
 
807
972
  if (parent) {
808
- const parentStep = this.getCurrentStep(parent);
973
+ const parentItem = this.getCurrentItem(parent);
974
+ const parentStep = this.getEffectiveStep(parent);
809
975
  if (parentStep.onSubPathCancel) {
810
976
  const ctx: PathStepContext = {
811
977
  pathId: parent.definition.id,
812
- stepId: parentStep.id,
978
+ stepId: parentItem.id,
813
979
  data: { ...parent.data },
814
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
980
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
815
981
  };
816
982
  this.applyPatch(
817
983
  await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
@@ -838,14 +1004,15 @@ export class PathEngine {
838
1004
  // The meta is stored on the parent, not the sub-path
839
1005
  const finishedMeta = parent.subPathMeta;
840
1006
  this.activePath = parent;
841
- const parentStep = this.getCurrentStep(parent);
1007
+ const parentItem = this.getCurrentItem(parent);
1008
+ const parentStep = this.getEffectiveStep(parent);
842
1009
 
843
1010
  if (parentStep.onSubPathComplete) {
844
1011
  const ctx: PathStepContext = {
845
1012
  pathId: parent.definition.id,
846
- stepId: parentStep.id,
1013
+ stepId: parentItem.id,
847
1014
  data: { ...parent.data },
848
- isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
1015
+ isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
849
1016
  };
850
1017
  this.applyPatch(
851
1018
  await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
@@ -859,8 +1026,12 @@ export class PathEngine {
859
1026
  snapshot: this.snapshot()!
860
1027
  });
861
1028
  } else {
1029
+ // Top-level path completed — call onComplete hook if defined
862
1030
  this.activePath = null;
863
1031
  this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1032
+ if (finished.definition.onComplete) {
1033
+ await finished.definition.onComplete(finishedData);
1034
+ }
864
1035
  }
865
1036
  }
866
1037
 
@@ -891,10 +1062,63 @@ export class PathEngine {
891
1062
  this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
892
1063
  }
893
1064
 
894
- 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 {
895
1067
  return active.definition.steps[active.currentStepIndex];
896
1068
  }
897
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
+
898
1122
  private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
899
1123
  if (patch && typeof patch === "object") {
900
1124
  const active = this.activePath;
@@ -912,15 +1136,15 @@ export class PathEngine {
912
1136
  active.currentStepIndex >= 0 &&
913
1137
  active.currentStepIndex < active.definition.steps.length
914
1138
  ) {
915
- const step = active.definition.steps[active.currentStepIndex];
916
- if (!step.shouldSkip) break;
1139
+ const item = active.definition.steps[active.currentStepIndex];
1140
+ if (!item.shouldSkip) break;
917
1141
  const ctx: PathStepContext = {
918
1142
  pathId: active.definition.id,
919
- stepId: step.id,
1143
+ stepId: item.id,
920
1144
  data: { ...active.data },
921
- isFirstEntry: !active.visitedStepIds.has(step.id)
1145
+ isFirstEntry: !active.visitedStepIds.has(item.id)
922
1146
  };
923
- const skip = await step.shouldSkip(ctx);
1147
+ const skip = await item.shouldSkip(ctx);
924
1148
  if (!skip) break;
925
1149
  active.currentStepIndex += direction;
926
1150
  }
@@ -931,19 +1155,31 @@ export class PathEngine {
931
1155
  this._hasAttemptedNext = false;
932
1156
  const active = this.activePath;
933
1157
  if (!active) return;
934
- const step = this.getCurrentStep(active);
935
- 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);
936
1172
  // Mark as visited before calling onEnter so re-entrant calls see the
937
1173
  // correct isFirstEntry value.
938
- active.visitedStepIds.add(step.id);
939
- if (!step.onEnter) return;
1174
+ active.visitedStepIds.add(item.id);
1175
+ if (!effectiveStep.onEnter) return;
940
1176
  const ctx: PathStepContext = {
941
1177
  pathId: active.definition.id,
942
- stepId: step.id,
1178
+ stepId: item.id,
943
1179
  data: { ...active.data },
944
1180
  isFirstEntry
945
1181
  };
946
- return step.onEnter(ctx);
1182
+ return effectiveStep.onEnter(ctx);
947
1183
  }
948
1184
 
949
1185
  private async leaveCurrentStep(
@@ -973,8 +1209,8 @@ export class PathEngine {
973
1209
  };
974
1210
  return step.canMoveNext(ctx);
975
1211
  }
976
- if (step.fieldMessages) {
977
- 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;
978
1214
  }
979
1215
  return true;
980
1216
  }
@@ -1012,12 +1248,12 @@ export class PathEngine {
1012
1248
  active: ActivePath
1013
1249
  ): boolean {
1014
1250
  if (!guard) return true;
1015
- const step = this.getCurrentStep(active);
1251
+ const item = this.getCurrentItem(active);
1016
1252
  const ctx: PathStepContext = {
1017
1253
  pathId: active.definition.id,
1018
- stepId: step.id,
1254
+ stepId: item.id,
1019
1255
  data: { ...active.data },
1020
- isFirstEntry: !active.visitedStepIds.has(step.id)
1256
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1021
1257
  };
1022
1258
  try {
1023
1259
  const result = guard(ctx);
@@ -1025,7 +1261,7 @@ export class PathEngine {
1025
1261
  // Async guard detected - warn and return optimistic default
1026
1262
  if (result && typeof result.then === "function") {
1027
1263
  console.warn(
1028
- `[pathwrite] Async guard detected on step "${step.id}". ` +
1264
+ `[pathwrite] Async guard detected on step "${item.id}". ` +
1029
1265
  `Guards in snapshots must be synchronous. ` +
1030
1266
  `Returning true (optimistic) as default. ` +
1031
1267
  `The async guard will still be enforced during actual navigation.`
@@ -1034,7 +1270,7 @@ export class PathEngine {
1034
1270
  return true;
1035
1271
  } catch (err) {
1036
1272
  console.warn(
1037
- `[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. ` +
1038
1274
  `Returning true (allow navigation) as a safe default. ` +
1039
1275
  `Note: guards are evaluated before onEnter runs on first entry — ` +
1040
1276
  `ensure guards handle missing/undefined data gracefully.`,
@@ -1047,24 +1283,24 @@ export class PathEngine {
1047
1283
  /**
1048
1284
  * Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
1049
1285
  * When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
1050
- * 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.
1051
1287
  * When neither is defined, returns `true`.
1052
1288
  */
1053
1289
  private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
1054
1290
  if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
1055
- if (step.fieldMessages) {
1056
- 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;
1057
1293
  }
1058
1294
  return true;
1059
1295
  }
1060
1296
 
1061
1297
  /**
1062
- * Evaluates a fieldMessages function synchronously for inclusion in the snapshot.
1298
+ * Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
1063
1299
  * If the hook is absent, returns `{}`.
1064
1300
  * If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
1065
1301
  * `undefined` values are stripped from the result — only fields with a defined message are included.
1066
1302
  *
1067
- * **Note:** Like guards, `fieldMessages` is evaluated before `onEnter` runs on first
1303
+ * **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
1068
1304
  * entry. Write it defensively so it does not throw when fields are absent.
1069
1305
  */
1070
1306
  private evaluateFieldMessagesSync(
@@ -1072,12 +1308,12 @@ export class PathEngine {
1072
1308
  active: ActivePath
1073
1309
  ): Record<string, string> {
1074
1310
  if (!fn) return {};
1075
- const step = this.getCurrentStep(active);
1311
+ const item = this.getCurrentItem(active);
1076
1312
  const ctx: PathStepContext = {
1077
1313
  pathId: active.definition.id,
1078
- stepId: step.id,
1314
+ stepId: item.id,
1079
1315
  data: { ...active.data },
1080
- isFirstEntry: !active.visitedStepIds.has(step.id)
1316
+ isFirstEntry: !active.visitedStepIds.has(item.id)
1081
1317
  };
1082
1318
  try {
1083
1319
  const result = fn(ctx);
@@ -1092,22 +1328,45 @@ export class PathEngine {
1092
1328
  }
1093
1329
  if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
1094
1330
  console.warn(
1095
- `[pathwrite] Async fieldMessages detected on step "${step.id}". ` +
1096
- `fieldMessages must be synchronous. Returning {} as default. ` +
1331
+ `[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
1332
+ `fieldErrors must be synchronous. Returning {} as default. ` +
1097
1333
  `Use synchronous validation or move async checks to canMoveNext.`
1098
1334
  );
1099
1335
  }
1100
1336
  return {};
1101
1337
  } catch (err) {
1102
1338
  console.warn(
1103
- `[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. ` +
1104
1340
  `Returning {} as a safe default. ` +
1105
- `Note: fieldMessages is evaluated before onEnter runs on first entry — ` +
1341
+ `Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
1106
1342
  `ensure it handles missing/undefined data gracefully.`,
1107
1343
  err
1108
1344
  );
1109
1345
  return {};
1110
1346
  }
1111
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
+ }
1112
1371
  }
1113
1372