@daltonr/pathwrite-core 0.3.0 → 0.5.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 +430 -14
- package/dist/index.d.ts +141 -0
- package/dist/index.js +232 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +349 -34
package/src/index.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The interface every path state store must implement.
|
|
22
|
+
*
|
|
23
|
+
* `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
|
|
24
|
+
* implementation. Any backend — MongoDB, Redis, localStorage, etc. —
|
|
25
|
+
* implements this interface and works with `httpPersistence` and
|
|
26
|
+
* `restoreOrStart` without any other changes.
|
|
27
|
+
*/
|
|
28
|
+
export interface PathStore {
|
|
29
|
+
save(key: string, state: SerializedPathState): Promise<void>;
|
|
30
|
+
load(key: string): Promise<SerializedPathState | null>;
|
|
31
|
+
delete(key: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
3
34
|
export interface PathStepContext<TData extends PathData = PathData> {
|
|
4
35
|
readonly pathId: string;
|
|
5
36
|
readonly stepId: string;
|
|
@@ -96,8 +127,21 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
96
127
|
data: TData;
|
|
97
128
|
}
|
|
98
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Identifies the public method that triggered a `stateChanged` event.
|
|
132
|
+
*/
|
|
133
|
+
export type StateChangeCause =
|
|
134
|
+
| "start"
|
|
135
|
+
| "next"
|
|
136
|
+
| "previous"
|
|
137
|
+
| "goToStep"
|
|
138
|
+
| "goToStepChecked"
|
|
139
|
+
| "setData"
|
|
140
|
+
| "cancel"
|
|
141
|
+
| "restart";
|
|
142
|
+
|
|
99
143
|
export type PathEvent =
|
|
100
|
-
| { type: "stateChanged"; snapshot: PathSnapshot }
|
|
144
|
+
| { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
|
|
101
145
|
| { type: "completed"; pathId: string; data: PathData }
|
|
102
146
|
| { type: "cancelled"; pathId: string; data: PathData }
|
|
103
147
|
| {
|
|
@@ -107,6 +151,84 @@ export type PathEvent =
|
|
|
107
151
|
snapshot: PathSnapshot;
|
|
108
152
|
};
|
|
109
153
|
|
|
154
|
+
/**
|
|
155
|
+
* A function called on every engine event. Observers are registered at
|
|
156
|
+
* construction time and receive every event for the lifetime of the engine.
|
|
157
|
+
*
|
|
158
|
+
* The second argument is the engine itself — useful when the observer needs to
|
|
159
|
+
* read current state (e.g. calling `engine.exportState()` for persistence).
|
|
160
|
+
*
|
|
161
|
+
* ```typescript
|
|
162
|
+
* const logger: PathObserver = (event) => console.log(event.type);
|
|
163
|
+
* const persist: PathObserver = (event, engine) => { ... };
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export type PathObserver = (event: PathEvent, engine: PathEngine) => void;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Determines which engine events an observer should react to.
|
|
170
|
+
*
|
|
171
|
+
* | Strategy | Triggers when |
|
|
172
|
+
* |---------------------|------------------------------------------------------------|
|
|
173
|
+
* | `"onEveryChange"` | Any settled `stateChanged` or `resumed` |
|
|
174
|
+
* | `"onNext"` | `next()` completes navigation *(default)* |
|
|
175
|
+
* | `"onSubPathComplete"` | Sub-path finishes and the parent resumes |
|
|
176
|
+
* | `"onComplete"` | The entire path completes |
|
|
177
|
+
* | `"manual"` | Never — caller decides when to act |
|
|
178
|
+
*/
|
|
179
|
+
export type ObserverStrategy =
|
|
180
|
+
| "onEveryChange"
|
|
181
|
+
| "onNext"
|
|
182
|
+
| "onSubPathComplete"
|
|
183
|
+
| "onComplete"
|
|
184
|
+
| "manual";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Returns `true` when `event` matches the trigger condition for `strategy`.
|
|
188
|
+
*
|
|
189
|
+
* Use this in any `PathObserver` factory to centralise the
|
|
190
|
+
* "which events should I react to?" decision so every observer
|
|
191
|
+
* (HTTP, MongoDB, logger, analytics…) shares the same semantics.
|
|
192
|
+
*
|
|
193
|
+
* ```typescript
|
|
194
|
+
* const observer: PathObserver = (event, engine) => {
|
|
195
|
+
* if (matchesStrategy(strategy, event)) doWork(engine);
|
|
196
|
+
* };
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean {
|
|
200
|
+
switch (strategy) {
|
|
201
|
+
case "onEveryChange":
|
|
202
|
+
// Only react once navigation has settled — stateChanged fires twice per
|
|
203
|
+
// navigation (isNavigating:true then false).
|
|
204
|
+
return (event.type === "stateChanged" && !event.snapshot.isNavigating)
|
|
205
|
+
|| event.type === "resumed";
|
|
206
|
+
case "onNext":
|
|
207
|
+
return event.type === "stateChanged"
|
|
208
|
+
&& event.cause === "next"
|
|
209
|
+
&& !event.snapshot.isNavigating;
|
|
210
|
+
case "onSubPathComplete":
|
|
211
|
+
return event.type === "resumed";
|
|
212
|
+
case "onComplete":
|
|
213
|
+
return event.type === "completed";
|
|
214
|
+
case "manual":
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Options accepted by the `PathEngine` constructor and `PathEngine.fromState()`.
|
|
221
|
+
*/
|
|
222
|
+
export interface PathEngineOptions {
|
|
223
|
+
/**
|
|
224
|
+
* Zero or more observers to register before the first event fires.
|
|
225
|
+
* Each observer is called synchronously on every engine event for the
|
|
226
|
+
* lifetime of the engine. Observers cannot be removed; for removable
|
|
227
|
+
* listeners use `engine.subscribe()`.
|
|
228
|
+
*/
|
|
229
|
+
observers?: PathObserver[];
|
|
230
|
+
}
|
|
231
|
+
|
|
110
232
|
interface ActivePath {
|
|
111
233
|
definition: PathDefinition;
|
|
112
234
|
currentStepIndex: number;
|
|
@@ -121,6 +243,81 @@ export class PathEngine {
|
|
|
121
243
|
private readonly listeners = new Set<(event: PathEvent) => void>();
|
|
122
244
|
private _isNavigating = false;
|
|
123
245
|
|
|
246
|
+
constructor(options?: PathEngineOptions) {
|
|
247
|
+
if (options?.observers) {
|
|
248
|
+
for (const observer of options.observers) {
|
|
249
|
+
// Wrap so observer receives the engine instance as the second argument
|
|
250
|
+
this.listeners.add((event) => observer(event, this));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Restores a PathEngine from previously exported state.
|
|
257
|
+
*
|
|
258
|
+
* **Important:** You must provide the same path definitions that were
|
|
259
|
+
* active when the state was exported. The path IDs in `state` are used
|
|
260
|
+
* to match against the provided definitions.
|
|
261
|
+
*
|
|
262
|
+
* @param state The serialized state from `exportState()`.
|
|
263
|
+
* @param pathDefinitions A map of path ID → definition. Must include the
|
|
264
|
+
* active path and any paths in the stack.
|
|
265
|
+
* @returns A new PathEngine instance with the restored state.
|
|
266
|
+
* @throws If `state` references a path ID not present in `pathDefinitions`,
|
|
267
|
+
* or if the state format is invalid.
|
|
268
|
+
*/
|
|
269
|
+
public static fromState(
|
|
270
|
+
state: SerializedPathState,
|
|
271
|
+
pathDefinitions: Record<string, PathDefinition>,
|
|
272
|
+
options?: PathEngineOptions
|
|
273
|
+
): PathEngine {
|
|
274
|
+
if (state.version !== 1) {
|
|
275
|
+
throw new Error(`Unsupported SerializedPathState version: ${state.version}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const engine = new PathEngine(options);
|
|
279
|
+
|
|
280
|
+
// Restore the path stack (sub-paths)
|
|
281
|
+
for (const stackItem of state.pathStack) {
|
|
282
|
+
const definition = pathDefinitions[stackItem.pathId];
|
|
283
|
+
if (!definition) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Cannot restore state: path definition "${stackItem.pathId}" not found. ` +
|
|
286
|
+
`Provide all path definitions that were active when state was exported.`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
engine.pathStack.push({
|
|
290
|
+
definition,
|
|
291
|
+
currentStepIndex: stackItem.currentStepIndex,
|
|
292
|
+
data: { ...stackItem.data },
|
|
293
|
+
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
294
|
+
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Restore the active path
|
|
299
|
+
const activeDefinition = pathDefinitions[state.pathId];
|
|
300
|
+
if (!activeDefinition) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Cannot restore state: active path definition "${state.pathId}" not found.`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
engine.activePath = {
|
|
307
|
+
definition: activeDefinition,
|
|
308
|
+
currentStepIndex: state.currentStepIndex,
|
|
309
|
+
data: { ...state.data },
|
|
310
|
+
visitedStepIds: new Set(state.visitedStepIds),
|
|
311
|
+
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
312
|
+
// from the parent when this path was started). On restore, it's undefined.
|
|
313
|
+
subPathMeta: undefined
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
engine._isNavigating = state._isNavigating;
|
|
317
|
+
|
|
318
|
+
return engine;
|
|
319
|
+
}
|
|
320
|
+
|
|
124
321
|
public subscribe(listener: (event: PathEvent) => void): () => void {
|
|
125
322
|
this.listeners.add(listener);
|
|
126
323
|
return () => this.listeners.delete(listener);
|
|
@@ -135,6 +332,26 @@ export class PathEngine {
|
|
|
135
332
|
return this._startAsync(path, initialData);
|
|
136
333
|
}
|
|
137
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Tears down any active path (and the entire sub-path stack) without firing
|
|
337
|
+
* lifecycle hooks or emitting `cancelled`, then immediately starts the given
|
|
338
|
+
* path from scratch.
|
|
339
|
+
*
|
|
340
|
+
* Safe to call at any time — whether a path is running, already completed,
|
|
341
|
+
* or has never been started. Use this to implement a "Start over" button or
|
|
342
|
+
* to retry a path after completion without remounting the host component.
|
|
343
|
+
*
|
|
344
|
+
* @param path The path definition to (re)start.
|
|
345
|
+
* @param initialData Data to seed the fresh path with. Defaults to `{}`.
|
|
346
|
+
*/
|
|
347
|
+
public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
|
|
348
|
+
this.assertPathHasSteps(path);
|
|
349
|
+
this._isNavigating = false;
|
|
350
|
+
this.activePath = null;
|
|
351
|
+
this.pathStack.length = 0;
|
|
352
|
+
return this._startAsync(path, initialData);
|
|
353
|
+
}
|
|
354
|
+
|
|
138
355
|
/**
|
|
139
356
|
* Starts a sub-path on top of the currently active path. Throws if no path
|
|
140
357
|
* is running.
|
|
@@ -171,9 +388,11 @@ export class PathEngine {
|
|
|
171
388
|
|
|
172
389
|
const cancelledPathId = active.definition.id;
|
|
173
390
|
const cancelledData = { ...active.data };
|
|
174
|
-
const cancelledMeta = active.subPathMeta;
|
|
175
391
|
|
|
176
392
|
if (this.pathStack.length > 0) {
|
|
393
|
+
// Get meta from the parent in the stack
|
|
394
|
+
const parent = this.pathStack[this.pathStack.length - 1];
|
|
395
|
+
const cancelledMeta = parent.subPathMeta;
|
|
177
396
|
return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
|
|
178
397
|
}
|
|
179
398
|
|
|
@@ -185,7 +404,7 @@ export class PathEngine {
|
|
|
185
404
|
public setData(key: string, value: unknown): Promise<void> {
|
|
186
405
|
const active = this.requireActivePath();
|
|
187
406
|
active.data[key] = value;
|
|
188
|
-
this.emitStateChanged();
|
|
407
|
+
this.emitStateChanged("setData");
|
|
189
408
|
return Promise.resolve();
|
|
190
409
|
}
|
|
191
410
|
|
|
@@ -258,6 +477,41 @@ export class PathEngine {
|
|
|
258
477
|
};
|
|
259
478
|
}
|
|
260
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Exports the current engine state as a plain JSON-serializable object.
|
|
482
|
+
* Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
|
|
483
|
+
* persist and restore wizard progress.
|
|
484
|
+
*
|
|
485
|
+
* Returns `null` if no path is active.
|
|
486
|
+
*
|
|
487
|
+
* **Important:** This only exports the _state_ (data, step position, etc.),
|
|
488
|
+
* not the path definition. When restoring, you must provide the same
|
|
489
|
+
* `PathDefinition` to `fromState()`.
|
|
490
|
+
*/
|
|
491
|
+
public exportState(): SerializedPathState | null {
|
|
492
|
+
if (this.activePath === null) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const active = this.activePath;
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
version: 1,
|
|
500
|
+
pathId: active.definition.id,
|
|
501
|
+
currentStepIndex: active.currentStepIndex,
|
|
502
|
+
data: { ...active.data },
|
|
503
|
+
visitedStepIds: Array.from(active.visitedStepIds),
|
|
504
|
+
pathStack: this.pathStack.map((p) => ({
|
|
505
|
+
pathId: p.definition.id,
|
|
506
|
+
currentStepIndex: p.currentStepIndex,
|
|
507
|
+
data: { ...p.data },
|
|
508
|
+
visitedStepIds: Array.from(p.visitedStepIds),
|
|
509
|
+
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
|
|
510
|
+
})),
|
|
511
|
+
_isNavigating: this._isNavigating
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
261
515
|
// ---------------------------------------------------------------------------
|
|
262
516
|
// Private async helpers
|
|
263
517
|
// ---------------------------------------------------------------------------
|
|
@@ -266,7 +520,12 @@ export class PathEngine {
|
|
|
266
520
|
if (this._isNavigating) return;
|
|
267
521
|
|
|
268
522
|
if (this.activePath !== null) {
|
|
269
|
-
|
|
523
|
+
// Store the meta on the parent before pushing to stack
|
|
524
|
+
const parentWithMeta: ActivePath = {
|
|
525
|
+
...this.activePath,
|
|
526
|
+
subPathMeta
|
|
527
|
+
};
|
|
528
|
+
this.pathStack.push(parentWithMeta);
|
|
270
529
|
}
|
|
271
530
|
|
|
272
531
|
this.activePath = {
|
|
@@ -274,7 +533,7 @@ export class PathEngine {
|
|
|
274
533
|
currentStepIndex: 0,
|
|
275
534
|
data: { ...initialData },
|
|
276
535
|
visitedStepIds: new Set(),
|
|
277
|
-
subPathMeta
|
|
536
|
+
subPathMeta: undefined
|
|
278
537
|
};
|
|
279
538
|
|
|
280
539
|
this._isNavigating = true;
|
|
@@ -287,15 +546,15 @@ export class PathEngine {
|
|
|
287
546
|
return;
|
|
288
547
|
}
|
|
289
548
|
|
|
290
|
-
this.emitStateChanged();
|
|
549
|
+
this.emitStateChanged("start");
|
|
291
550
|
|
|
292
551
|
try {
|
|
293
552
|
this.applyPatch(await this.enterCurrentStep());
|
|
294
553
|
this._isNavigating = false;
|
|
295
|
-
this.emitStateChanged();
|
|
554
|
+
this.emitStateChanged("start");
|
|
296
555
|
} catch (err) {
|
|
297
556
|
this._isNavigating = false;
|
|
298
|
-
this.emitStateChanged();
|
|
557
|
+
this.emitStateChanged("start");
|
|
299
558
|
throw err;
|
|
300
559
|
}
|
|
301
560
|
}
|
|
@@ -304,7 +563,7 @@ export class PathEngine {
|
|
|
304
563
|
if (this._isNavigating) return;
|
|
305
564
|
|
|
306
565
|
this._isNavigating = true;
|
|
307
|
-
this.emitStateChanged();
|
|
566
|
+
this.emitStateChanged("next");
|
|
308
567
|
|
|
309
568
|
try {
|
|
310
569
|
const step = this.getCurrentStep(active);
|
|
@@ -324,10 +583,10 @@ export class PathEngine {
|
|
|
324
583
|
}
|
|
325
584
|
|
|
326
585
|
this._isNavigating = false;
|
|
327
|
-
this.emitStateChanged();
|
|
586
|
+
this.emitStateChanged("next");
|
|
328
587
|
} catch (err) {
|
|
329
588
|
this._isNavigating = false;
|
|
330
|
-
this.emitStateChanged();
|
|
589
|
+
this.emitStateChanged("next");
|
|
331
590
|
throw err;
|
|
332
591
|
}
|
|
333
592
|
}
|
|
@@ -341,7 +600,7 @@ export class PathEngine {
|
|
|
341
600
|
if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
|
|
342
601
|
|
|
343
602
|
this._isNavigating = true;
|
|
344
|
-
this.emitStateChanged();
|
|
603
|
+
this.emitStateChanged("previous");
|
|
345
604
|
|
|
346
605
|
try {
|
|
347
606
|
const step = this.getCurrentStep(active);
|
|
@@ -361,10 +620,10 @@ export class PathEngine {
|
|
|
361
620
|
}
|
|
362
621
|
|
|
363
622
|
this._isNavigating = false;
|
|
364
|
-
this.emitStateChanged();
|
|
623
|
+
this.emitStateChanged("previous");
|
|
365
624
|
} catch (err) {
|
|
366
625
|
this._isNavigating = false;
|
|
367
|
-
this.emitStateChanged();
|
|
626
|
+
this.emitStateChanged("previous");
|
|
368
627
|
throw err;
|
|
369
628
|
}
|
|
370
629
|
}
|
|
@@ -373,7 +632,7 @@ export class PathEngine {
|
|
|
373
632
|
if (this._isNavigating) return;
|
|
374
633
|
|
|
375
634
|
this._isNavigating = true;
|
|
376
|
-
this.emitStateChanged();
|
|
635
|
+
this.emitStateChanged("goToStep");
|
|
377
636
|
|
|
378
637
|
try {
|
|
379
638
|
const currentStep = this.getCurrentStep(active);
|
|
@@ -383,10 +642,10 @@ export class PathEngine {
|
|
|
383
642
|
|
|
384
643
|
this.applyPatch(await this.enterCurrentStep());
|
|
385
644
|
this._isNavigating = false;
|
|
386
|
-
this.emitStateChanged();
|
|
645
|
+
this.emitStateChanged("goToStep");
|
|
387
646
|
} catch (err) {
|
|
388
647
|
this._isNavigating = false;
|
|
389
|
-
this.emitStateChanged();
|
|
648
|
+
this.emitStateChanged("goToStep");
|
|
390
649
|
throw err;
|
|
391
650
|
}
|
|
392
651
|
}
|
|
@@ -395,7 +654,7 @@ export class PathEngine {
|
|
|
395
654
|
if (this._isNavigating) return;
|
|
396
655
|
|
|
397
656
|
this._isNavigating = true;
|
|
398
|
-
this.emitStateChanged();
|
|
657
|
+
this.emitStateChanged("goToStepChecked");
|
|
399
658
|
|
|
400
659
|
try {
|
|
401
660
|
const currentStep = this.getCurrentStep(active);
|
|
@@ -411,10 +670,10 @@ export class PathEngine {
|
|
|
411
670
|
}
|
|
412
671
|
|
|
413
672
|
this._isNavigating = false;
|
|
414
|
-
this.emitStateChanged();
|
|
673
|
+
this.emitStateChanged("goToStepChecked");
|
|
415
674
|
} catch (err) {
|
|
416
675
|
this._isNavigating = false;
|
|
417
|
-
this.emitStateChanged();
|
|
676
|
+
this.emitStateChanged("goToStepChecked");
|
|
418
677
|
throw err;
|
|
419
678
|
}
|
|
420
679
|
}
|
|
@@ -430,7 +689,7 @@ export class PathEngine {
|
|
|
430
689
|
this.activePath = this.pathStack.pop() ?? null;
|
|
431
690
|
|
|
432
691
|
this._isNavigating = true;
|
|
433
|
-
this.emitStateChanged();
|
|
692
|
+
this.emitStateChanged("cancel");
|
|
434
693
|
|
|
435
694
|
try {
|
|
436
695
|
const parent = this.activePath;
|
|
@@ -451,10 +710,10 @@ export class PathEngine {
|
|
|
451
710
|
}
|
|
452
711
|
|
|
453
712
|
this._isNavigating = false;
|
|
454
|
-
this.emitStateChanged();
|
|
713
|
+
this.emitStateChanged("cancel");
|
|
455
714
|
} catch (err) {
|
|
456
715
|
this._isNavigating = false;
|
|
457
|
-
this.emitStateChanged();
|
|
716
|
+
this.emitStateChanged("cancel");
|
|
458
717
|
throw err;
|
|
459
718
|
}
|
|
460
719
|
}
|
|
@@ -463,11 +722,12 @@ export class PathEngine {
|
|
|
463
722
|
const finished = this.requireActivePath();
|
|
464
723
|
const finishedPathId = finished.definition.id;
|
|
465
724
|
const finishedData = { ...finished.data };
|
|
466
|
-
const finishedMeta = finished.subPathMeta;
|
|
467
725
|
|
|
468
726
|
if (this.pathStack.length > 0) {
|
|
469
|
-
|
|
470
|
-
|
|
727
|
+
const parent = this.pathStack.pop()!;
|
|
728
|
+
// The meta is stored on the parent, not the sub-path
|
|
729
|
+
const finishedMeta = parent.subPathMeta;
|
|
730
|
+
this.activePath = parent;
|
|
471
731
|
const parentStep = this.getCurrentStep(parent);
|
|
472
732
|
|
|
473
733
|
if (parentStep.onSubPathComplete) {
|
|
@@ -517,8 +777,8 @@ export class PathEngine {
|
|
|
517
777
|
}
|
|
518
778
|
}
|
|
519
779
|
|
|
520
|
-
private emitStateChanged(): void {
|
|
521
|
-
this.emit({ type: "stateChanged", snapshot: this.snapshot()! });
|
|
780
|
+
private emitStateChanged(cause: StateChangeCause): void {
|
|
781
|
+
this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
|
|
522
782
|
}
|
|
523
783
|
|
|
524
784
|
private getCurrentStep(active: ActivePath): PathStep {
|
|
@@ -620,6 +880,15 @@ export class PathEngine {
|
|
|
620
880
|
* Evaluates a guard function synchronously for inclusion in the snapshot.
|
|
621
881
|
* If the guard is absent, returns `true`.
|
|
622
882
|
* If the guard returns a `Promise`, returns `true` (optimistic default).
|
|
883
|
+
*
|
|
884
|
+
* **Note:** Guards are evaluated on every snapshot, including the very first one
|
|
885
|
+
* emitted at the start of a path — _before_ `onEnter` has run on that step.
|
|
886
|
+
* This means `data` will still reflect the `initialData` passed to `start()`.
|
|
887
|
+
* Write guards defensively (e.g. `(data.name ?? "").trim().length > 0`) so they
|
|
888
|
+
* do not throw when optional fields are absent on first entry.
|
|
889
|
+
*
|
|
890
|
+
* If a guard throws, the error is caught, a `console.warn` is emitted, and the
|
|
891
|
+
* safe default (`true`) is returned so the UI remains operable.
|
|
623
892
|
*/
|
|
624
893
|
private evaluateGuardSync(
|
|
625
894
|
guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
|
|
@@ -633,15 +902,41 @@ export class PathEngine {
|
|
|
633
902
|
data: { ...active.data },
|
|
634
903
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
635
904
|
};
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
905
|
+
try {
|
|
906
|
+
const result = guard(ctx);
|
|
907
|
+
if (typeof result === "boolean") return result;
|
|
908
|
+
// Async guard detected - warn and return optimistic default
|
|
909
|
+
if (result && typeof result.then === "function") {
|
|
910
|
+
console.warn(
|
|
911
|
+
`[pathwrite] Async guard detected on step "${step.id}". ` +
|
|
912
|
+
`Guards in snapshots must be synchronous. ` +
|
|
913
|
+
`Returning true (optimistic) as default. ` +
|
|
914
|
+
`The async guard will still be enforced during actual navigation.`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
return true;
|
|
918
|
+
} catch (err) {
|
|
919
|
+
console.warn(
|
|
920
|
+
`[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
|
|
921
|
+
`Returning true (allow navigation) as a safe default. ` +
|
|
922
|
+
`Note: guards are evaluated before onEnter runs on first entry — ` +
|
|
923
|
+
`ensure guards handle missing/undefined data gracefully.`,
|
|
924
|
+
err
|
|
925
|
+
);
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
639
928
|
}
|
|
640
929
|
|
|
641
930
|
/**
|
|
642
931
|
* Evaluates a validationMessages function synchronously for inclusion in the snapshot.
|
|
643
932
|
* If the hook is absent, returns `[]`.
|
|
644
933
|
* If the hook returns a `Promise`, returns `[]` (async hooks are not supported in snapshots).
|
|
934
|
+
*
|
|
935
|
+
* **Note:** Like guards, `validationMessages` is evaluated before `onEnter` runs on first
|
|
936
|
+
* entry. Write it defensively so it does not throw when fields are absent.
|
|
937
|
+
*
|
|
938
|
+
* If the function throws, the error is caught, a `console.warn` is emitted, and `[]`
|
|
939
|
+
* is returned so validation messages do not block the UI unexpectedly.
|
|
645
940
|
*/
|
|
646
941
|
private evaluateValidationMessagesSync(
|
|
647
942
|
fn: ((ctx: PathStepContext) => string[] | Promise<string[]>) | undefined,
|
|
@@ -655,9 +950,29 @@ export class PathEngine {
|
|
|
655
950
|
data: { ...active.data },
|
|
656
951
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
657
952
|
};
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
953
|
+
try {
|
|
954
|
+
const result = fn(ctx);
|
|
955
|
+
if (Array.isArray(result)) return result;
|
|
956
|
+
// Async validationMessages detected - warn and return empty array
|
|
957
|
+
if (result && typeof result.then === "function") {
|
|
958
|
+
console.warn(
|
|
959
|
+
`[pathwrite] Async validationMessages detected on step "${step.id}". ` +
|
|
960
|
+
`validationMessages in snapshots must be synchronous. ` +
|
|
961
|
+
`Returning [] as default. ` +
|
|
962
|
+
`Use synchronous validation or move async checks to canMoveNext.`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
return [];
|
|
966
|
+
} catch (err) {
|
|
967
|
+
console.warn(
|
|
968
|
+
`[pathwrite] validationMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
|
|
969
|
+
`Returning [] as a safe default. ` +
|
|
970
|
+
`Note: validationMessages is evaluated before onEnter runs on first entry — ` +
|
|
971
|
+
`ensure it handles missing/undefined data gracefully.`,
|
|
972
|
+
err
|
|
973
|
+
);
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
661
976
|
}
|
|
662
977
|
}
|
|
663
978
|
|