@daltonr/pathwrite-core 0.2.1 → 0.4.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/README.md +373 -8
- package/dist/index.d.ts +112 -4
- package/dist/index.js +247 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +330 -32
package/src/index.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
export type PathData = Record<string, unknown>;
|
|
2
2
|
|
|
3
|
+
export interface SerializedPathState {
|
|
4
|
+
version: 1;
|
|
5
|
+
pathId: string;
|
|
6
|
+
currentStepIndex: number;
|
|
7
|
+
data: PathData;
|
|
8
|
+
visitedStepIds: string[];
|
|
9
|
+
subPathMeta?: Record<string, unknown>;
|
|
10
|
+
pathStack: Array<{
|
|
11
|
+
pathId: string;
|
|
12
|
+
currentStepIndex: number;
|
|
13
|
+
data: PathData;
|
|
14
|
+
visitedStepIds: string[];
|
|
15
|
+
subPathMeta?: Record<string, unknown>;
|
|
16
|
+
}>;
|
|
17
|
+
_isNavigating: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
export interface PathStepContext<TData extends PathData = PathData> {
|
|
4
21
|
readonly pathId: string;
|
|
5
22
|
readonly stepId: string;
|
|
6
23
|
readonly data: Readonly<TData>;
|
|
24
|
+
/**
|
|
25
|
+
* `true` the first time this step is entered within the current path
|
|
26
|
+
* instance. `false` on all subsequent re-entries (e.g. navigating back
|
|
27
|
+
* then forward again). Use inside `onEnter` to distinguish initialisation
|
|
28
|
+
* from re-entry so you don't accidentally overwrite data the user has
|
|
29
|
+
* already filled in.
|
|
30
|
+
*/
|
|
31
|
+
readonly isFirstEntry: boolean;
|
|
7
32
|
}
|
|
8
33
|
|
|
9
34
|
export interface PathStep<TData extends PathData = PathData> {
|
|
@@ -22,10 +47,31 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
22
47
|
validationMessages?: (ctx: PathStepContext<TData>) => string[] | Promise<string[]>;
|
|
23
48
|
onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
24
49
|
onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
50
|
+
/**
|
|
51
|
+
* Called on the parent step when a sub-path completes naturally (user
|
|
52
|
+
* reached the last step). Receives the sub-path ID, its final data, the
|
|
53
|
+
* parent step context, and the optional `meta` object that was passed to
|
|
54
|
+
* `startSubPath()` for correlation (e.g. a collection item index).
|
|
55
|
+
*/
|
|
25
56
|
onSubPathComplete?: (
|
|
26
57
|
subPathId: string,
|
|
27
58
|
subPathData: PathData,
|
|
28
|
-
ctx: PathStepContext<TData
|
|
59
|
+
ctx: PathStepContext<TData>,
|
|
60
|
+
meta?: Record<string, unknown>
|
|
61
|
+
) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
62
|
+
/**
|
|
63
|
+
* Called on the parent step when a sub-path is cancelled — either via an
|
|
64
|
+
* explicit `cancel()` call or by pressing Back on the sub-path's first step.
|
|
65
|
+
* Receives the sub-path ID, its data at time of cancellation, the parent
|
|
66
|
+
* step context, and the optional `meta` passed to `startSubPath()`.
|
|
67
|
+
* Return a patch to update the parent path's data (e.g. to record a
|
|
68
|
+
* "skipped" or "declined" outcome).
|
|
69
|
+
*/
|
|
70
|
+
onSubPathCancel?: (
|
|
71
|
+
subPathId: string,
|
|
72
|
+
subPathData: PathData,
|
|
73
|
+
ctx: PathStepContext<TData>,
|
|
74
|
+
meta?: Record<string, unknown>
|
|
29
75
|
) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
30
76
|
}
|
|
31
77
|
|
|
@@ -82,6 +128,8 @@ interface ActivePath {
|
|
|
82
128
|
definition: PathDefinition;
|
|
83
129
|
currentStepIndex: number;
|
|
84
130
|
data: PathData;
|
|
131
|
+
visitedStepIds: Set<string>;
|
|
132
|
+
subPathMeta?: Record<string, unknown>;
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
export class PathEngine {
|
|
@@ -90,6 +138,71 @@ export class PathEngine {
|
|
|
90
138
|
private readonly listeners = new Set<(event: PathEvent) => void>();
|
|
91
139
|
private _isNavigating = false;
|
|
92
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Restores a PathEngine from previously exported state.
|
|
143
|
+
*
|
|
144
|
+
* **Important:** You must provide the same path definitions that were
|
|
145
|
+
* active when the state was exported. The path IDs in `state` are used
|
|
146
|
+
* to match against the provided definitions.
|
|
147
|
+
*
|
|
148
|
+
* @param state The serialized state from `exportState()`.
|
|
149
|
+
* @param pathDefinitions A map of path ID → definition. Must include the
|
|
150
|
+
* active path and any paths in the stack.
|
|
151
|
+
* @returns A new PathEngine instance with the restored state.
|
|
152
|
+
* @throws If `state` references a path ID not present in `pathDefinitions`,
|
|
153
|
+
* or if the state format is invalid.
|
|
154
|
+
*/
|
|
155
|
+
public static fromState(
|
|
156
|
+
state: SerializedPathState,
|
|
157
|
+
pathDefinitions: Record<string, PathDefinition>
|
|
158
|
+
): PathEngine {
|
|
159
|
+
if (state.version !== 1) {
|
|
160
|
+
throw new Error(`Unsupported SerializedPathState version: ${state.version}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const engine = new PathEngine();
|
|
164
|
+
|
|
165
|
+
// Restore the path stack (sub-paths)
|
|
166
|
+
for (const stackItem of state.pathStack) {
|
|
167
|
+
const definition = pathDefinitions[stackItem.pathId];
|
|
168
|
+
if (!definition) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Cannot restore state: path definition "${stackItem.pathId}" not found. ` +
|
|
171
|
+
`Provide all path definitions that were active when state was exported.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
engine.pathStack.push({
|
|
175
|
+
definition,
|
|
176
|
+
currentStepIndex: stackItem.currentStepIndex,
|
|
177
|
+
data: { ...stackItem.data },
|
|
178
|
+
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
179
|
+
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Restore the active path
|
|
184
|
+
const activeDefinition = pathDefinitions[state.pathId];
|
|
185
|
+
if (!activeDefinition) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Cannot restore state: active path definition "${state.pathId}" not found.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
engine.activePath = {
|
|
192
|
+
definition: activeDefinition,
|
|
193
|
+
currentStepIndex: state.currentStepIndex,
|
|
194
|
+
data: { ...state.data },
|
|
195
|
+
visitedStepIds: new Set(state.visitedStepIds),
|
|
196
|
+
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
197
|
+
// from the parent when this path was started). On restore, it's undefined.
|
|
198
|
+
subPathMeta: undefined
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
engine._isNavigating = state._isNavigating;
|
|
202
|
+
|
|
203
|
+
return engine;
|
|
204
|
+
}
|
|
205
|
+
|
|
93
206
|
public subscribe(listener: (event: PathEvent) => void): () => void {
|
|
94
207
|
this.listeners.add(listener);
|
|
95
208
|
return () => this.listeners.delete(listener);
|
|
@@ -104,10 +217,41 @@ export class PathEngine {
|
|
|
104
217
|
return this._startAsync(path, initialData);
|
|
105
218
|
}
|
|
106
219
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Tears down any active path (and the entire sub-path stack) without firing
|
|
222
|
+
* lifecycle hooks or emitting `cancelled`, then immediately starts the given
|
|
223
|
+
* path from scratch.
|
|
224
|
+
*
|
|
225
|
+
* Safe to call at any time — whether a path is running, already completed,
|
|
226
|
+
* or has never been started. Use this to implement a "Start over" button or
|
|
227
|
+
* to retry a path after completion without remounting the host component.
|
|
228
|
+
*
|
|
229
|
+
* @param path The path definition to (re)start.
|
|
230
|
+
* @param initialData Data to seed the fresh path with. Defaults to `{}`.
|
|
231
|
+
*/
|
|
232
|
+
public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
|
|
233
|
+
this.assertPathHasSteps(path);
|
|
234
|
+
this._isNavigating = false;
|
|
235
|
+
this.activePath = null;
|
|
236
|
+
this.pathStack.length = 0;
|
|
237
|
+
return this._startAsync(path, initialData);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Starts a sub-path on top of the currently active path. Throws if no path
|
|
242
|
+
* is running.
|
|
243
|
+
*
|
|
244
|
+
* @param path The sub-path definition to start.
|
|
245
|
+
* @param initialData Data to seed the sub-path with.
|
|
246
|
+
* @param meta Optional correlation object returned unchanged to the
|
|
247
|
+
* parent step's `onSubPathComplete` / `onSubPathCancel`
|
|
248
|
+
* hooks. Use to identify which collection item triggered
|
|
249
|
+
* the sub-path without embedding that information in the
|
|
250
|
+
* sub-path's own data.
|
|
251
|
+
*/
|
|
252
|
+
public startSubPath(path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>): Promise<void> {
|
|
109
253
|
this.requireActivePath();
|
|
110
|
-
return this.
|
|
254
|
+
return this._startAsync(path, initialData, meta);
|
|
111
255
|
}
|
|
112
256
|
|
|
113
257
|
public next(): Promise<void> {
|
|
@@ -120,7 +264,9 @@ export class PathEngine {
|
|
|
120
264
|
return this._previousAsync(active);
|
|
121
265
|
}
|
|
122
266
|
|
|
123
|
-
/** Cancel is synchronous (no hooks).
|
|
267
|
+
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
268
|
+
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
269
|
+
* API consistency. */
|
|
124
270
|
public cancel(): Promise<void> {
|
|
125
271
|
const active = this.requireActivePath();
|
|
126
272
|
if (this._isNavigating) return Promise.resolve();
|
|
@@ -129,9 +275,10 @@ export class PathEngine {
|
|
|
129
275
|
const cancelledData = { ...active.data };
|
|
130
276
|
|
|
131
277
|
if (this.pathStack.length > 0) {
|
|
132
|
-
|
|
133
|
-
this.
|
|
134
|
-
|
|
278
|
+
// Get meta from the parent in the stack
|
|
279
|
+
const parent = this.pathStack[this.pathStack.length - 1];
|
|
280
|
+
const cancelledMeta = parent.subPathMeta;
|
|
281
|
+
return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
|
|
135
282
|
}
|
|
136
283
|
|
|
137
284
|
this.activePath = null;
|
|
@@ -215,21 +362,63 @@ export class PathEngine {
|
|
|
215
362
|
};
|
|
216
363
|
}
|
|
217
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Exports the current engine state as a plain JSON-serializable object.
|
|
367
|
+
* Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
|
|
368
|
+
* persist and restore wizard progress.
|
|
369
|
+
*
|
|
370
|
+
* Returns `null` if no path is active.
|
|
371
|
+
*
|
|
372
|
+
* **Important:** This only exports the _state_ (data, step position, etc.),
|
|
373
|
+
* not the path definition. When restoring, you must provide the same
|
|
374
|
+
* `PathDefinition` to `fromState()`.
|
|
375
|
+
*/
|
|
376
|
+
public exportState(): SerializedPathState | null {
|
|
377
|
+
if (this.activePath === null) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const active = this.activePath;
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
version: 1,
|
|
385
|
+
pathId: active.definition.id,
|
|
386
|
+
currentStepIndex: active.currentStepIndex,
|
|
387
|
+
data: { ...active.data },
|
|
388
|
+
visitedStepIds: Array.from(active.visitedStepIds),
|
|
389
|
+
pathStack: this.pathStack.map((p) => ({
|
|
390
|
+
pathId: p.definition.id,
|
|
391
|
+
currentStepIndex: p.currentStepIndex,
|
|
392
|
+
data: { ...p.data },
|
|
393
|
+
visitedStepIds: Array.from(p.visitedStepIds),
|
|
394
|
+
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
|
|
395
|
+
})),
|
|
396
|
+
_isNavigating: this._isNavigating
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
218
400
|
// ---------------------------------------------------------------------------
|
|
219
401
|
// Private async helpers
|
|
220
402
|
// ---------------------------------------------------------------------------
|
|
221
403
|
|
|
222
|
-
private async _startAsync(path: PathDefinition, initialData: PathData): Promise<void> {
|
|
404
|
+
private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
|
|
223
405
|
if (this._isNavigating) return;
|
|
224
406
|
|
|
225
407
|
if (this.activePath !== null) {
|
|
226
|
-
|
|
408
|
+
// Store the meta on the parent before pushing to stack
|
|
409
|
+
const parentWithMeta: ActivePath = {
|
|
410
|
+
...this.activePath,
|
|
411
|
+
subPathMeta
|
|
412
|
+
};
|
|
413
|
+
this.pathStack.push(parentWithMeta);
|
|
227
414
|
}
|
|
228
415
|
|
|
229
416
|
this.activePath = {
|
|
230
417
|
definition: path,
|
|
231
418
|
currentStepIndex: 0,
|
|
232
|
-
data: { ...initialData }
|
|
419
|
+
data: { ...initialData },
|
|
420
|
+
visitedStepIds: new Set(),
|
|
421
|
+
subPathMeta: undefined
|
|
233
422
|
};
|
|
234
423
|
|
|
235
424
|
this._isNavigating = true;
|
|
@@ -374,24 +563,67 @@ export class PathEngine {
|
|
|
374
563
|
}
|
|
375
564
|
}
|
|
376
565
|
|
|
566
|
+
private async _cancelSubPathAsync(
|
|
567
|
+
cancelledPathId: string,
|
|
568
|
+
cancelledData: PathData,
|
|
569
|
+
cancelledMeta?: Record<string, unknown>
|
|
570
|
+
): Promise<void> {
|
|
571
|
+
// Pop the stack BEFORE emitting so snapshot() always reflects the parent
|
|
572
|
+
// path (which has a valid currentStepIndex) rather than the cancelled
|
|
573
|
+
// sub-path (which may have currentStepIndex = -1).
|
|
574
|
+
this.activePath = this.pathStack.pop() ?? null;
|
|
575
|
+
|
|
576
|
+
this._isNavigating = true;
|
|
577
|
+
this.emitStateChanged();
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const parent = this.activePath;
|
|
581
|
+
|
|
582
|
+
if (parent) {
|
|
583
|
+
const parentStep = this.getCurrentStep(parent);
|
|
584
|
+
if (parentStep.onSubPathCancel) {
|
|
585
|
+
const ctx: PathStepContext = {
|
|
586
|
+
pathId: parent.definition.id,
|
|
587
|
+
stepId: parentStep.id,
|
|
588
|
+
data: { ...parent.data },
|
|
589
|
+
isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
|
|
590
|
+
};
|
|
591
|
+
this.applyPatch(
|
|
592
|
+
await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
this._isNavigating = false;
|
|
598
|
+
this.emitStateChanged();
|
|
599
|
+
} catch (err) {
|
|
600
|
+
this._isNavigating = false;
|
|
601
|
+
this.emitStateChanged();
|
|
602
|
+
throw err;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
377
606
|
private async finishActivePath(): Promise<void> {
|
|
378
607
|
const finished = this.requireActivePath();
|
|
379
608
|
const finishedPathId = finished.definition.id;
|
|
380
609
|
const finishedData = { ...finished.data };
|
|
381
610
|
|
|
382
611
|
if (this.pathStack.length > 0) {
|
|
383
|
-
|
|
384
|
-
|
|
612
|
+
const parent = this.pathStack.pop()!;
|
|
613
|
+
// The meta is stored on the parent, not the sub-path
|
|
614
|
+
const finishedMeta = parent.subPathMeta;
|
|
615
|
+
this.activePath = parent;
|
|
385
616
|
const parentStep = this.getCurrentStep(parent);
|
|
386
617
|
|
|
387
618
|
if (parentStep.onSubPathComplete) {
|
|
388
619
|
const ctx: PathStepContext = {
|
|
389
620
|
pathId: parent.definition.id,
|
|
390
621
|
stepId: parentStep.id,
|
|
391
|
-
data: { ...parent.data }
|
|
622
|
+
data: { ...parent.data },
|
|
623
|
+
isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
|
|
392
624
|
};
|
|
393
625
|
this.applyPatch(
|
|
394
|
-
await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx)
|
|
626
|
+
await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
|
|
395
627
|
);
|
|
396
628
|
}
|
|
397
629
|
|
|
@@ -460,7 +692,8 @@ export class PathEngine {
|
|
|
460
692
|
const ctx: PathStepContext = {
|
|
461
693
|
pathId: active.definition.id,
|
|
462
694
|
stepId: step.id,
|
|
463
|
-
data: { ...active.data }
|
|
695
|
+
data: { ...active.data },
|
|
696
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
464
697
|
};
|
|
465
698
|
const skip = await step.shouldSkip(ctx);
|
|
466
699
|
if (!skip) break;
|
|
@@ -472,11 +705,16 @@ export class PathEngine {
|
|
|
472
705
|
const active = this.activePath;
|
|
473
706
|
if (!active) return;
|
|
474
707
|
const step = this.getCurrentStep(active);
|
|
708
|
+
const isFirstEntry = !active.visitedStepIds.has(step.id);
|
|
709
|
+
// Mark as visited before calling onEnter so re-entrant calls see the
|
|
710
|
+
// correct isFirstEntry value.
|
|
711
|
+
active.visitedStepIds.add(step.id);
|
|
475
712
|
if (!step.onEnter) return;
|
|
476
713
|
const ctx: PathStepContext = {
|
|
477
714
|
pathId: active.definition.id,
|
|
478
715
|
stepId: step.id,
|
|
479
|
-
data: { ...active.data }
|
|
716
|
+
data: { ...active.data },
|
|
717
|
+
isFirstEntry
|
|
480
718
|
};
|
|
481
719
|
return step.onEnter(ctx);
|
|
482
720
|
}
|
|
@@ -489,7 +727,8 @@ export class PathEngine {
|
|
|
489
727
|
const ctx: PathStepContext = {
|
|
490
728
|
pathId: active.definition.id,
|
|
491
729
|
stepId: step.id,
|
|
492
|
-
data: { ...active.data }
|
|
730
|
+
data: { ...active.data },
|
|
731
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
493
732
|
};
|
|
494
733
|
return step.onLeave(ctx);
|
|
495
734
|
}
|
|
@@ -502,7 +741,8 @@ export class PathEngine {
|
|
|
502
741
|
const ctx: PathStepContext = {
|
|
503
742
|
pathId: active.definition.id,
|
|
504
743
|
stepId: step.id,
|
|
505
|
-
data: { ...active.data }
|
|
744
|
+
data: { ...active.data },
|
|
745
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
506
746
|
};
|
|
507
747
|
return step.canMoveNext(ctx);
|
|
508
748
|
}
|
|
@@ -515,7 +755,8 @@ export class PathEngine {
|
|
|
515
755
|
const ctx: PathStepContext = {
|
|
516
756
|
pathId: active.definition.id,
|
|
517
757
|
stepId: step.id,
|
|
518
|
-
data: { ...active.data }
|
|
758
|
+
data: { ...active.data },
|
|
759
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
519
760
|
};
|
|
520
761
|
return step.canMovePrevious(ctx);
|
|
521
762
|
}
|
|
@@ -524,42 +765,99 @@ export class PathEngine {
|
|
|
524
765
|
* Evaluates a guard function synchronously for inclusion in the snapshot.
|
|
525
766
|
* If the guard is absent, returns `true`.
|
|
526
767
|
* If the guard returns a `Promise`, returns `true` (optimistic default).
|
|
768
|
+
*
|
|
769
|
+
* **Note:** Guards are evaluated on every snapshot, including the very first one
|
|
770
|
+
* emitted at the start of a path — _before_ `onEnter` has run on that step.
|
|
771
|
+
* This means `data` will still reflect the `initialData` passed to `start()`.
|
|
772
|
+
* Write guards defensively (e.g. `(data.name ?? "").trim().length > 0`) so they
|
|
773
|
+
* do not throw when optional fields are absent on first entry.
|
|
774
|
+
*
|
|
775
|
+
* If a guard throws, the error is caught, a `console.warn` is emitted, and the
|
|
776
|
+
* safe default (`true`) is returned so the UI remains operable.
|
|
527
777
|
*/
|
|
528
778
|
private evaluateGuardSync(
|
|
529
779
|
guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
|
|
530
780
|
active: ActivePath
|
|
531
781
|
): boolean {
|
|
532
782
|
if (!guard) return true;
|
|
783
|
+
const step = this.getCurrentStep(active);
|
|
533
784
|
const ctx: PathStepContext = {
|
|
534
785
|
pathId: active.definition.id,
|
|
535
|
-
stepId:
|
|
536
|
-
data: { ...active.data }
|
|
786
|
+
stepId: step.id,
|
|
787
|
+
data: { ...active.data },
|
|
788
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
537
789
|
};
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
790
|
+
try {
|
|
791
|
+
const result = guard(ctx);
|
|
792
|
+
if (typeof result === "boolean") return result;
|
|
793
|
+
// Async guard detected - warn and return optimistic default
|
|
794
|
+
if (result && typeof result.then === "function") {
|
|
795
|
+
console.warn(
|
|
796
|
+
`[pathwrite] Async guard detected on step "${step.id}". ` +
|
|
797
|
+
`Guards in snapshots must be synchronous. ` +
|
|
798
|
+
`Returning true (optimistic) as default. ` +
|
|
799
|
+
`The async guard will still be enforced during actual navigation.`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
return true;
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.warn(
|
|
805
|
+
`[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
|
|
806
|
+
`Returning true (allow navigation) as a safe default. ` +
|
|
807
|
+
`Note: guards are evaluated before onEnter runs on first entry — ` +
|
|
808
|
+
`ensure guards handle missing/undefined data gracefully.`,
|
|
809
|
+
err
|
|
810
|
+
);
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
542
813
|
}
|
|
543
814
|
|
|
544
815
|
/**
|
|
545
816
|
* Evaluates a validationMessages function synchronously for inclusion in the snapshot.
|
|
546
817
|
* If the hook is absent, returns `[]`.
|
|
547
818
|
* If the hook returns a `Promise`, returns `[]` (async hooks are not supported in snapshots).
|
|
819
|
+
*
|
|
820
|
+
* **Note:** Like guards, `validationMessages` is evaluated before `onEnter` runs on first
|
|
821
|
+
* entry. Write it defensively so it does not throw when fields are absent.
|
|
822
|
+
*
|
|
823
|
+
* If the function throws, the error is caught, a `console.warn` is emitted, and `[]`
|
|
824
|
+
* is returned so validation messages do not block the UI unexpectedly.
|
|
548
825
|
*/
|
|
549
826
|
private evaluateValidationMessagesSync(
|
|
550
827
|
fn: ((ctx: PathStepContext) => string[] | Promise<string[]>) | undefined,
|
|
551
828
|
active: ActivePath
|
|
552
829
|
): string[] {
|
|
553
830
|
if (!fn) return [];
|
|
831
|
+
const step = this.getCurrentStep(active);
|
|
554
832
|
const ctx: PathStepContext = {
|
|
555
833
|
pathId: active.definition.id,
|
|
556
|
-
stepId:
|
|
557
|
-
data: { ...active.data }
|
|
834
|
+
stepId: step.id,
|
|
835
|
+
data: { ...active.data },
|
|
836
|
+
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
558
837
|
};
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
838
|
+
try {
|
|
839
|
+
const result = fn(ctx);
|
|
840
|
+
if (Array.isArray(result)) return result;
|
|
841
|
+
// Async validationMessages detected - warn and return empty array
|
|
842
|
+
if (result && typeof result.then === "function") {
|
|
843
|
+
console.warn(
|
|
844
|
+
`[pathwrite] Async validationMessages detected on step "${step.id}". ` +
|
|
845
|
+
`validationMessages in snapshots must be synchronous. ` +
|
|
846
|
+
`Returning [] as default. ` +
|
|
847
|
+
`Use synchronous validation or move async checks to canMoveNext.`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
return [];
|
|
851
|
+
} catch (err) {
|
|
852
|
+
console.warn(
|
|
853
|
+
`[pathwrite] validationMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
|
|
854
|
+
`Returning [] as a safe default. ` +
|
|
855
|
+
`Note: validationMessages is evaluated before onEnter runs on first entry — ` +
|
|
856
|
+
`ensure it handles missing/undefined data gracefully.`,
|
|
857
|
+
err
|
|
858
|
+
);
|
|
859
|
+
return [];
|
|
860
|
+
}
|
|
563
861
|
}
|
|
564
862
|
}
|
|
565
863
|
|