@daltonr/pathwrite-core 0.9.0 → 0.10.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/dist/index.d.ts +158 -7
- package/dist/index.js +297 -91
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +404 -101
package/dist/index.js
CHANGED
|
@@ -14,14 +14,15 @@
|
|
|
14
14
|
export function matchesStrategy(strategy, event) {
|
|
15
15
|
switch (strategy) {
|
|
16
16
|
case "onEveryChange":
|
|
17
|
-
// Only react once
|
|
18
|
-
//
|
|
19
|
-
return (event.type === "stateChanged" &&
|
|
17
|
+
// Only react once the engine has settled — stateChanged fires on every
|
|
18
|
+
// phase transition; only "idle" and "error" are settled states.
|
|
19
|
+
return (event.type === "stateChanged" &&
|
|
20
|
+
(event.snapshot.status === "idle" || event.snapshot.status === "error"))
|
|
20
21
|
|| event.type === "resumed";
|
|
21
22
|
case "onNext":
|
|
22
23
|
return event.type === "stateChanged"
|
|
23
24
|
&& event.cause === "next"
|
|
24
|
-
&&
|
|
25
|
+
&& (event.snapshot.status === "idle" || event.snapshot.status === "error");
|
|
25
26
|
case "onSubPathComplete":
|
|
26
27
|
return event.type === "resumed";
|
|
27
28
|
case "onComplete":
|
|
@@ -33,16 +34,51 @@ export function matchesStrategy(strategy, event) {
|
|
|
33
34
|
function isStepChoice(item) {
|
|
34
35
|
return "select" in item && "steps" in item;
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Converts a camelCase or lowercase field key to a display label.
|
|
39
|
+
* `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
|
|
40
|
+
* Used by shells to render labeled field-error summaries.
|
|
41
|
+
*/
|
|
42
|
+
export function formatFieldKey(key) {
|
|
43
|
+
return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns a human-readable description of which operation failed, keyed by
|
|
47
|
+
* the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
|
|
48
|
+
* the error panel message.
|
|
49
|
+
*/
|
|
50
|
+
export function errorPhaseMessage(phase) {
|
|
51
|
+
switch (phase) {
|
|
52
|
+
case "entering": return "Failed to load this step.";
|
|
53
|
+
case "validating": return "The check could not be completed.";
|
|
54
|
+
case "leaving": return "Failed to save your progress.";
|
|
55
|
+
case "completing": return "Your submission could not be sent.";
|
|
56
|
+
default: return "An unexpected error occurred.";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
36
59
|
export class PathEngine {
|
|
37
60
|
activePath = null;
|
|
38
61
|
pathStack = [];
|
|
39
62
|
listeners = new Set();
|
|
40
|
-
|
|
63
|
+
_status = "idle";
|
|
41
64
|
/** True after the user has called next() on the current step at least once. Resets on step entry. */
|
|
42
65
|
_hasAttemptedNext = false;
|
|
66
|
+
/** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
|
|
67
|
+
_blockingError = null;
|
|
43
68
|
/** The path and initial data from the most recent top-level start() call. Used by restart(). */
|
|
44
69
|
_rootPath = null;
|
|
45
70
|
_rootInitialData = {};
|
|
71
|
+
/** Structured error from the most recent failed async operation. Null when no error is active. */
|
|
72
|
+
_error = null;
|
|
73
|
+
/** Stored retry function. Null when no error is pending. */
|
|
74
|
+
_pendingRetry = null;
|
|
75
|
+
/**
|
|
76
|
+
* Counts how many times `retry()` has been called for the current error sequence.
|
|
77
|
+
* Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
|
|
78
|
+
*/
|
|
79
|
+
_retryCount = 0;
|
|
80
|
+
_hasPersistence = false;
|
|
81
|
+
_hasWarnedAsyncShouldSkip = false;
|
|
46
82
|
constructor(options) {
|
|
47
83
|
if (options?.observers) {
|
|
48
84
|
for (const observer of options.observers) {
|
|
@@ -50,6 +86,9 @@ export class PathEngine {
|
|
|
50
86
|
this.listeners.add((event) => observer(event, this));
|
|
51
87
|
}
|
|
52
88
|
}
|
|
89
|
+
if (options?.hasPersistence) {
|
|
90
|
+
this._hasPersistence = true;
|
|
91
|
+
}
|
|
53
92
|
}
|
|
54
93
|
/**
|
|
55
94
|
* Restores a PathEngine from previously exported state.
|
|
@@ -82,6 +121,7 @@ export class PathEngine {
|
|
|
82
121
|
currentStepIndex: stackItem.currentStepIndex,
|
|
83
122
|
data: { ...stackItem.data },
|
|
84
123
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
124
|
+
resolvedSkips: new Set(),
|
|
85
125
|
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
86
126
|
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
87
127
|
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
@@ -97,13 +137,14 @@ export class PathEngine {
|
|
|
97
137
|
currentStepIndex: state.currentStepIndex,
|
|
98
138
|
data: { ...state.data },
|
|
99
139
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
140
|
+
resolvedSkips: new Set(),
|
|
100
141
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
101
142
|
// from the parent when this path was started). On restore, it's undefined.
|
|
102
143
|
subPathMeta: undefined,
|
|
103
144
|
stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
|
|
104
145
|
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
105
146
|
};
|
|
106
|
-
engine.
|
|
147
|
+
engine._status = state._status ?? "idle";
|
|
107
148
|
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
108
149
|
// always recomputed from current data on restore).
|
|
109
150
|
for (const stackItem of engine.pathStack) {
|
|
@@ -140,7 +181,8 @@ export class PathEngine {
|
|
|
140
181
|
if (!this._rootPath) {
|
|
141
182
|
throw new Error("Cannot restart: engine has not been started. Call start() first.");
|
|
142
183
|
}
|
|
143
|
-
this.
|
|
184
|
+
this._status = "idle";
|
|
185
|
+
this._blockingError = null;
|
|
144
186
|
this.activePath = null;
|
|
145
187
|
this.pathStack.length = 0;
|
|
146
188
|
return this._startAsync(this._rootPath, { ...this._rootInitialData });
|
|
@@ -163,18 +205,68 @@ export class PathEngine {
|
|
|
163
205
|
}
|
|
164
206
|
next() {
|
|
165
207
|
const active = this.requireActivePath();
|
|
208
|
+
// Reset the retry sequence. If we're recovering from an error the user
|
|
209
|
+
// explicitly clicked Next again — clear the error and reset to idle so
|
|
210
|
+
// _nextAsync's entry guard passes. For any other non-idle status (busy)
|
|
211
|
+
// the guard in _nextAsync will drop this call.
|
|
212
|
+
this._retryCount = 0;
|
|
213
|
+
this._error = null;
|
|
214
|
+
this._pendingRetry = null;
|
|
215
|
+
if (this._status === "error")
|
|
216
|
+
this._status = "idle";
|
|
166
217
|
return this._nextAsync(active);
|
|
167
218
|
}
|
|
168
219
|
previous() {
|
|
169
220
|
const active = this.requireActivePath();
|
|
170
221
|
return this._previousAsync(active);
|
|
171
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Re-runs the operation that caused the most recent `snapshot.error`.
|
|
225
|
+
* Increments `snapshot.error.retryCount` so shells can escalate from
|
|
226
|
+
* "Try again" to "Come back later" after repeated failures.
|
|
227
|
+
*
|
|
228
|
+
* No-op if there is no pending error or if navigation is in progress.
|
|
229
|
+
*/
|
|
230
|
+
retry() {
|
|
231
|
+
if (!this._pendingRetry || this._status !== "error")
|
|
232
|
+
return Promise.resolve();
|
|
233
|
+
this._retryCount++;
|
|
234
|
+
const fn = this._pendingRetry;
|
|
235
|
+
this._pendingRetry = null;
|
|
236
|
+
this._error = null;
|
|
237
|
+
this._status = "idle"; // allow the retry fn's entry guard to pass
|
|
238
|
+
return fn();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Pauses the path with intent to return. Preserves all state and data.
|
|
242
|
+
*
|
|
243
|
+
* - Clears any active error state
|
|
244
|
+
* - Emits a `suspended` event that the application can listen for to dismiss
|
|
245
|
+
* the wizard UI (close a modal, navigate away, etc.)
|
|
246
|
+
* - The engine remains in its current state — call `start()` / `restoreOrStart()`
|
|
247
|
+
* to resume when the user returns
|
|
248
|
+
*
|
|
249
|
+
* Use in the "Come back later" escalation path when `snapshot.error.retryCount`
|
|
250
|
+
* has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
|
|
251
|
+
* the UI; Pathwrite's persistence layer handles saving progress automatically via
|
|
252
|
+
* the configured store and observer strategy.
|
|
253
|
+
*/
|
|
254
|
+
suspend() {
|
|
255
|
+
const active = this.activePath;
|
|
256
|
+
const pathId = active?.definition.id ?? "";
|
|
257
|
+
const data = active ? { ...active.data } : {};
|
|
258
|
+
this._error = null;
|
|
259
|
+
this._pendingRetry = null;
|
|
260
|
+
this._status = "idle";
|
|
261
|
+
this.emit({ type: "suspended", pathId, data });
|
|
262
|
+
return Promise.resolve();
|
|
263
|
+
}
|
|
172
264
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
173
265
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
174
266
|
* API consistency. */
|
|
175
267
|
async cancel() {
|
|
176
268
|
const active = this.requireActivePath();
|
|
177
|
-
if (this.
|
|
269
|
+
if (this._status !== "idle")
|
|
178
270
|
return;
|
|
179
271
|
const cancelledPathId = active.definition.id;
|
|
180
272
|
const cancelledData = { ...active.data };
|
|
@@ -247,24 +339,32 @@ export class PathEngine {
|
|
|
247
339
|
const item = this.getCurrentItem(active);
|
|
248
340
|
const effectiveStep = this.getEffectiveStep(active);
|
|
249
341
|
const { steps } = active.definition;
|
|
250
|
-
|
|
342
|
+
// Filter out steps confirmed as skipped during navigation. Steps not yet
|
|
343
|
+
// evaluated (e.g. on first render) are included optimistically.
|
|
344
|
+
const visibleSteps = steps.filter(s => !active.resolvedSkips.has(s.id));
|
|
345
|
+
const stepCount = visibleSteps.length;
|
|
346
|
+
const visibleIndex = visibleSteps.findIndex(s => s.id === item.id);
|
|
347
|
+
// Fall back to raw index if not found (should not happen in normal use)
|
|
348
|
+
const effectiveStepIndex = visibleIndex >= 0 ? visibleIndex : active.currentStepIndex;
|
|
251
349
|
// Build rootProgress from the bottom of the stack (the top-level path)
|
|
252
350
|
let rootProgress;
|
|
253
351
|
if (this.pathStack.length > 0) {
|
|
254
352
|
const root = this.pathStack[0];
|
|
255
|
-
const
|
|
256
|
-
const rootStepCount =
|
|
353
|
+
const rootVisibleSteps = root.definition.steps.filter(s => !root.resolvedSkips.has(s.id));
|
|
354
|
+
const rootStepCount = rootVisibleSteps.length;
|
|
355
|
+
const rootVisibleIndex = rootVisibleSteps.findIndex(s => s.id === root.definition.steps[root.currentStepIndex]?.id);
|
|
356
|
+
const rootEffectiveIndex = rootVisibleIndex >= 0 ? rootVisibleIndex : root.currentStepIndex;
|
|
257
357
|
rootProgress = {
|
|
258
358
|
pathId: root.definition.id,
|
|
259
|
-
stepIndex:
|
|
359
|
+
stepIndex: rootEffectiveIndex,
|
|
260
360
|
stepCount: rootStepCount,
|
|
261
|
-
progress: rootStepCount <= 1 ? 1 :
|
|
262
|
-
steps:
|
|
361
|
+
progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
|
|
362
|
+
steps: rootVisibleSteps.map((s, i) => ({
|
|
263
363
|
id: s.id,
|
|
264
364
|
title: s.title,
|
|
265
365
|
meta: s.meta,
|
|
266
|
-
status: i <
|
|
267
|
-
: i ===
|
|
366
|
+
status: i < rootEffectiveIndex ? "completed"
|
|
367
|
+
: i === rootEffectiveIndex ? "current"
|
|
268
368
|
: "upcoming"
|
|
269
369
|
}))
|
|
270
370
|
};
|
|
@@ -275,24 +375,27 @@ export class PathEngine {
|
|
|
275
375
|
stepTitle: effectiveStep.title ?? item.title,
|
|
276
376
|
stepMeta: effectiveStep.meta ?? item.meta,
|
|
277
377
|
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
278
|
-
stepIndex:
|
|
378
|
+
stepIndex: effectiveStepIndex,
|
|
279
379
|
stepCount,
|
|
280
|
-
progress: stepCount <= 1 ? 1 :
|
|
281
|
-
steps:
|
|
380
|
+
progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
|
|
381
|
+
steps: visibleSteps.map((s, i) => ({
|
|
282
382
|
id: s.id,
|
|
283
383
|
title: s.title,
|
|
284
384
|
meta: s.meta,
|
|
285
|
-
status: i <
|
|
286
|
-
: i ===
|
|
385
|
+
status: i < effectiveStepIndex ? "completed"
|
|
386
|
+
: i === effectiveStepIndex ? "current"
|
|
287
387
|
: "upcoming"
|
|
288
388
|
})),
|
|
289
|
-
isFirstStep:
|
|
290
|
-
isLastStep:
|
|
389
|
+
isFirstStep: effectiveStepIndex === 0,
|
|
390
|
+
isLastStep: effectiveStepIndex === stepCount - 1 &&
|
|
291
391
|
this.pathStack.length === 0,
|
|
292
392
|
nestingLevel: this.pathStack.length,
|
|
293
393
|
rootProgress,
|
|
294
|
-
|
|
394
|
+
status: this._status,
|
|
395
|
+
error: this._error,
|
|
396
|
+
hasPersistence: this._hasPersistence,
|
|
295
397
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
398
|
+
blockingError: this._blockingError,
|
|
296
399
|
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
297
400
|
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
298
401
|
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
@@ -335,14 +438,14 @@ export class PathEngine {
|
|
|
335
438
|
stepEntryData: { ...p.stepEntryData },
|
|
336
439
|
stepEnteredAt: p.stepEnteredAt
|
|
337
440
|
})),
|
|
338
|
-
|
|
441
|
+
_status: this._status
|
|
339
442
|
};
|
|
340
443
|
}
|
|
341
444
|
// ---------------------------------------------------------------------------
|
|
342
445
|
// Private async helpers
|
|
343
446
|
// ---------------------------------------------------------------------------
|
|
344
447
|
async _startAsync(path, initialData, subPathMeta) {
|
|
345
|
-
if (this.
|
|
448
|
+
if (this._status !== "idle")
|
|
346
449
|
return;
|
|
347
450
|
if (this.activePath !== null) {
|
|
348
451
|
// Store the meta on the parent before pushing to stack
|
|
@@ -357,131 +460,147 @@ export class PathEngine {
|
|
|
357
460
|
currentStepIndex: 0,
|
|
358
461
|
data: { ...initialData },
|
|
359
462
|
visitedStepIds: new Set(),
|
|
463
|
+
resolvedSkips: new Set(),
|
|
360
464
|
subPathMeta: undefined,
|
|
361
465
|
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
362
466
|
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
363
467
|
};
|
|
364
|
-
this._isNavigating = true;
|
|
365
468
|
await this.skipSteps(1);
|
|
366
469
|
if (this.activePath.currentStepIndex >= path.steps.length) {
|
|
367
|
-
this.
|
|
368
|
-
await this.finishActivePath();
|
|
470
|
+
await this._finishActivePathWithErrorHandling();
|
|
369
471
|
return;
|
|
370
472
|
}
|
|
473
|
+
this._status = "entering";
|
|
371
474
|
this.emitStateChanged("start");
|
|
372
|
-
|
|
373
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
374
|
-
this._isNavigating = false;
|
|
375
|
-
this.emitStateChanged("start");
|
|
376
|
-
}
|
|
377
|
-
catch (err) {
|
|
378
|
-
this._isNavigating = false;
|
|
379
|
-
this.emitStateChanged("start");
|
|
380
|
-
throw err;
|
|
381
|
-
}
|
|
475
|
+
await this._enterCurrentStepWithErrorHandling("start");
|
|
382
476
|
}
|
|
383
477
|
async _nextAsync(active) {
|
|
384
|
-
if (this.
|
|
478
|
+
if (this._status !== "idle")
|
|
385
479
|
return;
|
|
386
480
|
// Record that the user has attempted to advance — used by shells and step
|
|
387
481
|
// templates to gate error display ("punish late, reward early").
|
|
388
482
|
this._hasAttemptedNext = true;
|
|
389
|
-
|
|
483
|
+
// Phase: validating — canMoveNext guard
|
|
484
|
+
this._status = "validating";
|
|
390
485
|
this.emitStateChanged("next");
|
|
486
|
+
let guardResult;
|
|
391
487
|
try {
|
|
392
|
-
|
|
393
|
-
if (await this.canMoveNext(active, step)) {
|
|
394
|
-
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
395
|
-
active.currentStepIndex += 1;
|
|
396
|
-
await this.skipSteps(1);
|
|
397
|
-
if (active.currentStepIndex >= active.definition.steps.length) {
|
|
398
|
-
this._isNavigating = false;
|
|
399
|
-
await this.finishActivePath();
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
403
|
-
}
|
|
404
|
-
this._isNavigating = false;
|
|
405
|
-
this.emitStateChanged("next");
|
|
488
|
+
guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
|
|
406
489
|
}
|
|
407
490
|
catch (err) {
|
|
408
|
-
this.
|
|
491
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
|
|
492
|
+
this._pendingRetry = () => this._nextAsync(active);
|
|
493
|
+
this._status = "error";
|
|
409
494
|
this.emitStateChanged("next");
|
|
410
|
-
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (guardResult.allowed) {
|
|
498
|
+
// Phase: leaving — onLeave hook
|
|
499
|
+
this._status = "leaving";
|
|
500
|
+
this.emitStateChanged("next");
|
|
501
|
+
try {
|
|
502
|
+
this.applyPatch(await this.leaveCurrentStep(active, this.getEffectiveStep(active)));
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "leaving", retryCount: this._retryCount };
|
|
506
|
+
this._pendingRetry = () => this._nextAsync(active);
|
|
507
|
+
this._status = "error";
|
|
508
|
+
this.emitStateChanged("next");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
active.currentStepIndex += 1;
|
|
512
|
+
await this.skipSteps(1);
|
|
513
|
+
if (active.currentStepIndex >= active.definition.steps.length) {
|
|
514
|
+
// Phase: completing — PathDefinition.onComplete
|
|
515
|
+
await this._finishActivePathWithErrorHandling();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// Phase: entering — onEnter hook on the new step
|
|
519
|
+
await this._enterCurrentStepWithErrorHandling("next");
|
|
520
|
+
return;
|
|
411
521
|
}
|
|
522
|
+
this._blockingError = guardResult.reason;
|
|
523
|
+
this._status = "idle";
|
|
524
|
+
this.emitStateChanged("next");
|
|
412
525
|
}
|
|
413
526
|
async _previousAsync(active) {
|
|
414
|
-
if (this.
|
|
527
|
+
if (this._status !== "idle")
|
|
415
528
|
return;
|
|
416
529
|
// No-op when already on the first step of a top-level path.
|
|
417
530
|
// Sub-paths still cancel/pop back to the parent when previous() is called
|
|
418
531
|
// on their first step (the currentStepIndex < 0 branch below handles that).
|
|
419
532
|
if (active.currentStepIndex === 0 && this.pathStack.length === 0)
|
|
420
533
|
return;
|
|
421
|
-
this.
|
|
534
|
+
this._status = "leaving";
|
|
422
535
|
this.emitStateChanged("previous");
|
|
423
536
|
try {
|
|
424
537
|
const step = this.getEffectiveStep(active);
|
|
425
|
-
|
|
538
|
+
const prevGuard = await this.canMovePrevious(active, step);
|
|
539
|
+
if (!prevGuard.allowed)
|
|
540
|
+
this._blockingError = prevGuard.reason;
|
|
541
|
+
if (prevGuard.allowed) {
|
|
426
542
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
427
543
|
active.currentStepIndex -= 1;
|
|
428
544
|
await this.skipSteps(-1);
|
|
429
545
|
if (active.currentStepIndex < 0) {
|
|
430
|
-
this.
|
|
546
|
+
this._status = "idle";
|
|
431
547
|
await this.cancel();
|
|
432
548
|
return;
|
|
433
549
|
}
|
|
434
550
|
this.applyPatch(await this.enterCurrentStep());
|
|
435
551
|
}
|
|
436
|
-
this.
|
|
552
|
+
this._status = "idle";
|
|
437
553
|
this.emitStateChanged("previous");
|
|
438
554
|
}
|
|
439
555
|
catch (err) {
|
|
440
|
-
this.
|
|
556
|
+
this._status = "idle";
|
|
441
557
|
this.emitStateChanged("previous");
|
|
442
558
|
throw err;
|
|
443
559
|
}
|
|
444
560
|
}
|
|
445
561
|
async _goToStepAsync(active, targetIndex) {
|
|
446
|
-
if (this.
|
|
562
|
+
if (this._status !== "idle")
|
|
447
563
|
return;
|
|
448
|
-
this.
|
|
564
|
+
this._status = "leaving";
|
|
449
565
|
this.emitStateChanged("goToStep");
|
|
450
566
|
try {
|
|
451
567
|
const currentStep = this.getEffectiveStep(active);
|
|
452
568
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
453
569
|
active.currentStepIndex = targetIndex;
|
|
454
570
|
this.applyPatch(await this.enterCurrentStep());
|
|
455
|
-
this.
|
|
571
|
+
this._status = "idle";
|
|
456
572
|
this.emitStateChanged("goToStep");
|
|
457
573
|
}
|
|
458
574
|
catch (err) {
|
|
459
|
-
this.
|
|
575
|
+
this._status = "idle";
|
|
460
576
|
this.emitStateChanged("goToStep");
|
|
461
577
|
throw err;
|
|
462
578
|
}
|
|
463
579
|
}
|
|
464
580
|
async _goToStepCheckedAsync(active, targetIndex) {
|
|
465
|
-
if (this.
|
|
581
|
+
if (this._status !== "idle")
|
|
466
582
|
return;
|
|
467
|
-
this.
|
|
583
|
+
this._status = "validating";
|
|
468
584
|
this.emitStateChanged("goToStepChecked");
|
|
469
585
|
try {
|
|
470
586
|
const currentStep = this.getEffectiveStep(active);
|
|
471
587
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
472
|
-
const
|
|
588
|
+
const guardResult = goingForward
|
|
473
589
|
? await this.canMoveNext(active, currentStep)
|
|
474
590
|
: await this.canMovePrevious(active, currentStep);
|
|
475
|
-
if (allowed)
|
|
591
|
+
if (!guardResult.allowed)
|
|
592
|
+
this._blockingError = guardResult.reason;
|
|
593
|
+
if (guardResult.allowed) {
|
|
594
|
+
this._status = "leaving";
|
|
476
595
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
477
596
|
active.currentStepIndex = targetIndex;
|
|
478
597
|
this.applyPatch(await this.enterCurrentStep());
|
|
479
598
|
}
|
|
480
|
-
this.
|
|
599
|
+
this._status = "idle";
|
|
481
600
|
this.emitStateChanged("goToStepChecked");
|
|
482
601
|
}
|
|
483
602
|
catch (err) {
|
|
484
|
-
this.
|
|
603
|
+
this._status = "idle";
|
|
485
604
|
this.emitStateChanged("goToStepChecked");
|
|
486
605
|
throw err;
|
|
487
606
|
}
|
|
@@ -491,7 +610,7 @@ export class PathEngine {
|
|
|
491
610
|
// path (which has a valid currentStepIndex) rather than the cancelled
|
|
492
611
|
// sub-path (which may have currentStepIndex = -1).
|
|
493
612
|
this.activePath = this.pathStack.pop() ?? null;
|
|
494
|
-
this.
|
|
613
|
+
this._status = "leaving";
|
|
495
614
|
this.emitStateChanged("cancel");
|
|
496
615
|
try {
|
|
497
616
|
const parent = this.activePath;
|
|
@@ -508,11 +627,11 @@ export class PathEngine {
|
|
|
508
627
|
this.applyPatch(await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta));
|
|
509
628
|
}
|
|
510
629
|
}
|
|
511
|
-
this.
|
|
630
|
+
this._status = "idle";
|
|
512
631
|
this.emitStateChanged("cancel");
|
|
513
632
|
}
|
|
514
633
|
catch (err) {
|
|
515
|
-
this.
|
|
634
|
+
this._status = "idle";
|
|
516
635
|
this.emitStateChanged("cancel");
|
|
517
636
|
throw err;
|
|
518
637
|
}
|
|
@@ -545,17 +664,75 @@ export class PathEngine {
|
|
|
545
664
|
});
|
|
546
665
|
}
|
|
547
666
|
else {
|
|
548
|
-
// Top-level path completed — call onComplete
|
|
549
|
-
|
|
550
|
-
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
667
|
+
// Top-level path completed — call onComplete before clearing activePath so
|
|
668
|
+
// that if it throws the engine remains on the final step and can retry.
|
|
551
669
|
if (finished.definition.onComplete) {
|
|
552
670
|
await finished.definition.onComplete(finishedData);
|
|
553
671
|
}
|
|
672
|
+
this.activePath = null;
|
|
673
|
+
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Wraps `finishActivePath` with error handling for the `completing` phase.
|
|
678
|
+
* On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
|
|
679
|
+
* resets status to `"error"`, and emits `stateChanged`.
|
|
680
|
+
* On success: resets status to `"idle"` (finishActivePath sets activePath = null,
|
|
681
|
+
* so no stateChanged is needed — the `completed` event is the terminal signal).
|
|
682
|
+
*/
|
|
683
|
+
async _finishActivePathWithErrorHandling() {
|
|
684
|
+
const active = this.activePath;
|
|
685
|
+
this._status = "completing";
|
|
686
|
+
try {
|
|
687
|
+
await this.finishActivePath();
|
|
688
|
+
this._status = "idle";
|
|
689
|
+
// No stateChanged here — finishActivePath emits "completed" or "resumed"
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "completing", retryCount: this._retryCount };
|
|
693
|
+
// Retry: call finishActivePath again (activePath is still set because onComplete
|
|
694
|
+
// throws before this.activePath = null in the restructured finishActivePath)
|
|
695
|
+
this._pendingRetry = () => this._finishActivePathWithErrorHandling();
|
|
696
|
+
this._status = "error";
|
|
697
|
+
if (active) {
|
|
698
|
+
// Restore activePath if it was cleared mid-throw (defensive)
|
|
699
|
+
if (!this.activePath)
|
|
700
|
+
this.activePath = active;
|
|
701
|
+
// Back up to the last valid step so snapshot() can render it while error is shown
|
|
702
|
+
if (this.activePath.currentStepIndex >= this.activePath.definition.steps.length) {
|
|
703
|
+
this.activePath.currentStepIndex = this.activePath.definition.steps.length - 1;
|
|
704
|
+
}
|
|
705
|
+
this.emitStateChanged("next");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Wraps `enterCurrentStep` with error handling for the `entering` phase.
|
|
711
|
+
* Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
|
|
712
|
+
* On failure: sets `_error`, stores a retry that re-calls this method,
|
|
713
|
+
* resets status to `"error"`, and emits `stateChanged` with the given `cause`.
|
|
714
|
+
*/
|
|
715
|
+
async _enterCurrentStepWithErrorHandling(cause) {
|
|
716
|
+
this._status = "entering";
|
|
717
|
+
try {
|
|
718
|
+
this.applyPatch(await this.enterCurrentStep());
|
|
719
|
+
this._status = "idle";
|
|
720
|
+
this.emitStateChanged(cause);
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "entering", retryCount: this._retryCount };
|
|
724
|
+
// Retry: re-enter the current step (don't repeat guards/leave)
|
|
725
|
+
this._pendingRetry = () => this._enterCurrentStepWithErrorHandling(cause);
|
|
726
|
+
this._status = "error";
|
|
727
|
+
this.emitStateChanged(cause);
|
|
554
728
|
}
|
|
555
729
|
}
|
|
556
730
|
// ---------------------------------------------------------------------------
|
|
557
731
|
// Private helpers
|
|
558
732
|
// ---------------------------------------------------------------------------
|
|
733
|
+
static errorMessage(err) {
|
|
734
|
+
return err instanceof Error ? err.message : String(err);
|
|
735
|
+
}
|
|
559
736
|
requireActivePath() {
|
|
560
737
|
if (this.activePath === null) {
|
|
561
738
|
throw new Error("No active path.");
|
|
@@ -642,23 +819,41 @@ export class PathEngine {
|
|
|
642
819
|
while (active.currentStepIndex >= 0 &&
|
|
643
820
|
active.currentStepIndex < active.definition.steps.length) {
|
|
644
821
|
const item = active.definition.steps[active.currentStepIndex];
|
|
645
|
-
if (!item.shouldSkip)
|
|
822
|
+
if (!item.shouldSkip) {
|
|
823
|
+
// This step has no shouldSkip — it is definitely visible. Remove it from
|
|
824
|
+
// the cache in case a previous navigation had marked it as skipped.
|
|
825
|
+
active.resolvedSkips.delete(item.id);
|
|
646
826
|
break;
|
|
827
|
+
}
|
|
647
828
|
const ctx = {
|
|
648
829
|
pathId: active.definition.id,
|
|
649
830
|
stepId: item.id,
|
|
650
831
|
data: { ...active.data },
|
|
651
832
|
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
652
833
|
};
|
|
653
|
-
const
|
|
654
|
-
if (
|
|
834
|
+
const rawResult = item.shouldSkip(ctx);
|
|
835
|
+
if (rawResult && typeof rawResult.then === "function") {
|
|
836
|
+
if (!this._hasWarnedAsyncShouldSkip) {
|
|
837
|
+
this._hasWarnedAsyncShouldSkip = true;
|
|
838
|
+
console.warn(`[Pathwrite] Step "${item.id}" has an async shouldSkip. ` +
|
|
839
|
+
`snapshot().stepCount and progress may be approximate until after the first navigation.`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const skip = await rawResult;
|
|
843
|
+
if (!skip) {
|
|
844
|
+
// This step resolved as NOT skipped — remove from cache in case it was
|
|
845
|
+
// previously skipped (data changed since last navigation).
|
|
846
|
+
active.resolvedSkips.delete(item.id);
|
|
655
847
|
break;
|
|
848
|
+
}
|
|
849
|
+
active.resolvedSkips.add(item.id);
|
|
656
850
|
active.currentStepIndex += direction;
|
|
657
851
|
}
|
|
658
852
|
}
|
|
659
853
|
async enterCurrentStep() {
|
|
660
854
|
// Each step starts fresh — errors are not shown until the user attempts to proceed.
|
|
661
855
|
this._hasAttemptedNext = false;
|
|
856
|
+
this._blockingError = null;
|
|
662
857
|
const active = this.activePath;
|
|
663
858
|
if (!active)
|
|
664
859
|
return;
|
|
@@ -703,23 +898,31 @@ export class PathEngine {
|
|
|
703
898
|
data: { ...active.data },
|
|
704
899
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
705
900
|
};
|
|
706
|
-
|
|
901
|
+
const result = await step.canMoveNext(ctx);
|
|
902
|
+
return PathEngine.normaliseGuardResult(result);
|
|
707
903
|
}
|
|
708
904
|
if (step.fieldErrors) {
|
|
709
|
-
|
|
905
|
+
const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
906
|
+
return { allowed, reason: null };
|
|
710
907
|
}
|
|
711
|
-
return true;
|
|
908
|
+
return { allowed: true, reason: null };
|
|
712
909
|
}
|
|
713
910
|
async canMovePrevious(active, step) {
|
|
714
911
|
if (!step.canMovePrevious)
|
|
715
|
-
return true;
|
|
912
|
+
return { allowed: true, reason: null };
|
|
716
913
|
const ctx = {
|
|
717
914
|
pathId: active.definition.id,
|
|
718
915
|
stepId: step.id,
|
|
719
916
|
data: { ...active.data },
|
|
720
917
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
721
918
|
};
|
|
722
|
-
|
|
919
|
+
const result = await step.canMovePrevious(ctx);
|
|
920
|
+
return PathEngine.normaliseGuardResult(result);
|
|
921
|
+
}
|
|
922
|
+
static normaliseGuardResult(result) {
|
|
923
|
+
if (result === true)
|
|
924
|
+
return { allowed: true, reason: null };
|
|
925
|
+
return { allowed: false, reason: result.reason ?? null };
|
|
723
926
|
}
|
|
724
927
|
/**
|
|
725
928
|
* Evaluates a guard function synchronously for inclusion in the snapshot.
|
|
@@ -747,16 +950,19 @@ export class PathEngine {
|
|
|
747
950
|
};
|
|
748
951
|
try {
|
|
749
952
|
const result = guard(ctx);
|
|
750
|
-
if (
|
|
751
|
-
return
|
|
752
|
-
// Async guard detected - warn and return optimistic default
|
|
953
|
+
if (result === true)
|
|
954
|
+
return true;
|
|
753
955
|
if (result && typeof result.then === "function") {
|
|
956
|
+
// Async guard detected - suppress the unhandled rejection, warn, return optimistic default
|
|
957
|
+
result.catch(() => { });
|
|
754
958
|
console.warn(`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
755
959
|
`Guards in snapshots must be synchronous. ` +
|
|
756
960
|
`Returning true (optimistic) as default. ` +
|
|
757
961
|
`The async guard will still be enforced during actual navigation.`);
|
|
962
|
+
return true;
|
|
758
963
|
}
|
|
759
|
-
|
|
964
|
+
// { allowed: false, reason? } object returned synchronously
|
|
965
|
+
return false;
|
|
760
966
|
}
|
|
761
967
|
catch (err) {
|
|
762
968
|
console.warn(`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|