@daltonr/pathwrite-core 0.8.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/README.md +1 -1
- package/dist/index.d.ts +168 -15
- package/dist/index.js +311 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +420 -111
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,13 +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;
|
|
68
|
+
/** The path and initial data from the most recent top-level start() call. Used by restart(). */
|
|
69
|
+
_rootPath = null;
|
|
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;
|
|
43
82
|
constructor(options) {
|
|
44
83
|
if (options?.observers) {
|
|
45
84
|
for (const observer of options.observers) {
|
|
@@ -47,6 +86,9 @@ export class PathEngine {
|
|
|
47
86
|
this.listeners.add((event) => observer(event, this));
|
|
48
87
|
}
|
|
49
88
|
}
|
|
89
|
+
if (options?.hasPersistence) {
|
|
90
|
+
this._hasPersistence = true;
|
|
91
|
+
}
|
|
50
92
|
}
|
|
51
93
|
/**
|
|
52
94
|
* Restores a PathEngine from previously exported state.
|
|
@@ -79,6 +121,7 @@ export class PathEngine {
|
|
|
79
121
|
currentStepIndex: stackItem.currentStepIndex,
|
|
80
122
|
data: { ...stackItem.data },
|
|
81
123
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
124
|
+
resolvedSkips: new Set(),
|
|
82
125
|
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
83
126
|
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
84
127
|
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
@@ -94,13 +137,14 @@ export class PathEngine {
|
|
|
94
137
|
currentStepIndex: state.currentStepIndex,
|
|
95
138
|
data: { ...state.data },
|
|
96
139
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
140
|
+
resolvedSkips: new Set(),
|
|
97
141
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
98
142
|
// from the parent when this path was started). On restore, it's undefined.
|
|
99
143
|
subPathMeta: undefined,
|
|
100
144
|
stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
|
|
101
145
|
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
102
146
|
};
|
|
103
|
-
engine.
|
|
147
|
+
engine._status = state._status ?? "idle";
|
|
104
148
|
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
105
149
|
// always recomputed from current data on restore).
|
|
106
150
|
for (const stackItem of engine.pathStack) {
|
|
@@ -118,26 +162,30 @@ export class PathEngine {
|
|
|
118
162
|
// ---------------------------------------------------------------------------
|
|
119
163
|
start(path, initialData = {}) {
|
|
120
164
|
this.assertPathHasSteps(path);
|
|
165
|
+
this._rootPath = path;
|
|
166
|
+
this._rootInitialData = initialData;
|
|
121
167
|
return this._startAsync(path, initialData);
|
|
122
168
|
}
|
|
123
169
|
/**
|
|
124
170
|
* Tears down any active path (and the entire sub-path stack) without firing
|
|
125
|
-
* lifecycle hooks or emitting `cancelled`, then immediately
|
|
126
|
-
* path
|
|
171
|
+
* lifecycle hooks or emitting `cancelled`, then immediately restarts the same
|
|
172
|
+
* path with the same initial data that was passed to the original `start()` call.
|
|
127
173
|
*
|
|
128
174
|
* Safe to call at any time — whether a path is running, already completed,
|
|
129
175
|
* or has never been started. Use this to implement a "Start over" button or
|
|
130
176
|
* to retry a path after completion without remounting the host component.
|
|
131
177
|
*
|
|
132
|
-
* @
|
|
133
|
-
* @param initialData Data to seed the fresh path with. Defaults to `{}`.
|
|
178
|
+
* @throws If `restart()` is called before `start()` has ever been called.
|
|
134
179
|
*/
|
|
135
|
-
restart(
|
|
136
|
-
this.
|
|
137
|
-
|
|
180
|
+
restart() {
|
|
181
|
+
if (!this._rootPath) {
|
|
182
|
+
throw new Error("Cannot restart: engine has not been started. Call start() first.");
|
|
183
|
+
}
|
|
184
|
+
this._status = "idle";
|
|
185
|
+
this._blockingError = null;
|
|
138
186
|
this.activePath = null;
|
|
139
187
|
this.pathStack.length = 0;
|
|
140
|
-
return this._startAsync(
|
|
188
|
+
return this._startAsync(this._rootPath, { ...this._rootInitialData });
|
|
141
189
|
}
|
|
142
190
|
/**
|
|
143
191
|
* Starts a sub-path on top of the currently active path. Throws if no path
|
|
@@ -157,18 +205,68 @@ export class PathEngine {
|
|
|
157
205
|
}
|
|
158
206
|
next() {
|
|
159
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";
|
|
160
217
|
return this._nextAsync(active);
|
|
161
218
|
}
|
|
162
219
|
previous() {
|
|
163
220
|
const active = this.requireActivePath();
|
|
164
221
|
return this._previousAsync(active);
|
|
165
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
|
+
}
|
|
166
264
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
167
265
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
168
266
|
* API consistency. */
|
|
169
267
|
async cancel() {
|
|
170
268
|
const active = this.requireActivePath();
|
|
171
|
-
if (this.
|
|
269
|
+
if (this._status !== "idle")
|
|
172
270
|
return;
|
|
173
271
|
const cancelledPathId = active.definition.id;
|
|
174
272
|
const cancelledData = { ...active.data };
|
|
@@ -241,24 +339,32 @@ export class PathEngine {
|
|
|
241
339
|
const item = this.getCurrentItem(active);
|
|
242
340
|
const effectiveStep = this.getEffectiveStep(active);
|
|
243
341
|
const { steps } = active.definition;
|
|
244
|
-
|
|
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;
|
|
245
349
|
// Build rootProgress from the bottom of the stack (the top-level path)
|
|
246
350
|
let rootProgress;
|
|
247
351
|
if (this.pathStack.length > 0) {
|
|
248
352
|
const root = this.pathStack[0];
|
|
249
|
-
const
|
|
250
|
-
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;
|
|
251
357
|
rootProgress = {
|
|
252
358
|
pathId: root.definition.id,
|
|
253
|
-
stepIndex:
|
|
359
|
+
stepIndex: rootEffectiveIndex,
|
|
254
360
|
stepCount: rootStepCount,
|
|
255
|
-
progress: rootStepCount <= 1 ? 1 :
|
|
256
|
-
steps:
|
|
361
|
+
progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
|
|
362
|
+
steps: rootVisibleSteps.map((s, i) => ({
|
|
257
363
|
id: s.id,
|
|
258
364
|
title: s.title,
|
|
259
365
|
meta: s.meta,
|
|
260
|
-
status: i <
|
|
261
|
-
: i ===
|
|
366
|
+
status: i < rootEffectiveIndex ? "completed"
|
|
367
|
+
: i === rootEffectiveIndex ? "current"
|
|
262
368
|
: "upcoming"
|
|
263
369
|
}))
|
|
264
370
|
};
|
|
@@ -269,24 +375,27 @@ export class PathEngine {
|
|
|
269
375
|
stepTitle: effectiveStep.title ?? item.title,
|
|
270
376
|
stepMeta: effectiveStep.meta ?? item.meta,
|
|
271
377
|
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
272
|
-
stepIndex:
|
|
378
|
+
stepIndex: effectiveStepIndex,
|
|
273
379
|
stepCount,
|
|
274
|
-
progress: stepCount <= 1 ? 1 :
|
|
275
|
-
steps:
|
|
380
|
+
progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
|
|
381
|
+
steps: visibleSteps.map((s, i) => ({
|
|
276
382
|
id: s.id,
|
|
277
383
|
title: s.title,
|
|
278
384
|
meta: s.meta,
|
|
279
|
-
status: i <
|
|
280
|
-
: i ===
|
|
385
|
+
status: i < effectiveStepIndex ? "completed"
|
|
386
|
+
: i === effectiveStepIndex ? "current"
|
|
281
387
|
: "upcoming"
|
|
282
388
|
})),
|
|
283
|
-
isFirstStep:
|
|
284
|
-
isLastStep:
|
|
389
|
+
isFirstStep: effectiveStepIndex === 0,
|
|
390
|
+
isLastStep: effectiveStepIndex === stepCount - 1 &&
|
|
285
391
|
this.pathStack.length === 0,
|
|
286
392
|
nestingLevel: this.pathStack.length,
|
|
287
393
|
rootProgress,
|
|
288
|
-
|
|
394
|
+
status: this._status,
|
|
395
|
+
error: this._error,
|
|
396
|
+
hasPersistence: this._hasPersistence,
|
|
289
397
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
398
|
+
blockingError: this._blockingError,
|
|
290
399
|
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
291
400
|
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
292
401
|
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
@@ -298,7 +407,7 @@ export class PathEngine {
|
|
|
298
407
|
}
|
|
299
408
|
/**
|
|
300
409
|
* Exports the current engine state as a plain JSON-serializable object.
|
|
301
|
-
* Use with storage adapters (e.g. `@daltonr/pathwrite-store
|
|
410
|
+
* Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
|
|
302
411
|
* persist and restore wizard progress.
|
|
303
412
|
*
|
|
304
413
|
* Returns `null` if no path is active.
|
|
@@ -329,14 +438,14 @@ export class PathEngine {
|
|
|
329
438
|
stepEntryData: { ...p.stepEntryData },
|
|
330
439
|
stepEnteredAt: p.stepEnteredAt
|
|
331
440
|
})),
|
|
332
|
-
|
|
441
|
+
_status: this._status
|
|
333
442
|
};
|
|
334
443
|
}
|
|
335
444
|
// ---------------------------------------------------------------------------
|
|
336
445
|
// Private async helpers
|
|
337
446
|
// ---------------------------------------------------------------------------
|
|
338
447
|
async _startAsync(path, initialData, subPathMeta) {
|
|
339
|
-
if (this.
|
|
448
|
+
if (this._status !== "idle")
|
|
340
449
|
return;
|
|
341
450
|
if (this.activePath !== null) {
|
|
342
451
|
// Store the meta on the parent before pushing to stack
|
|
@@ -351,131 +460,147 @@ export class PathEngine {
|
|
|
351
460
|
currentStepIndex: 0,
|
|
352
461
|
data: { ...initialData },
|
|
353
462
|
visitedStepIds: new Set(),
|
|
463
|
+
resolvedSkips: new Set(),
|
|
354
464
|
subPathMeta: undefined,
|
|
355
465
|
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
356
466
|
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
357
467
|
};
|
|
358
|
-
this._isNavigating = true;
|
|
359
468
|
await this.skipSteps(1);
|
|
360
469
|
if (this.activePath.currentStepIndex >= path.steps.length) {
|
|
361
|
-
this.
|
|
362
|
-
await this.finishActivePath();
|
|
470
|
+
await this._finishActivePathWithErrorHandling();
|
|
363
471
|
return;
|
|
364
472
|
}
|
|
473
|
+
this._status = "entering";
|
|
365
474
|
this.emitStateChanged("start");
|
|
366
|
-
|
|
367
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
368
|
-
this._isNavigating = false;
|
|
369
|
-
this.emitStateChanged("start");
|
|
370
|
-
}
|
|
371
|
-
catch (err) {
|
|
372
|
-
this._isNavigating = false;
|
|
373
|
-
this.emitStateChanged("start");
|
|
374
|
-
throw err;
|
|
375
|
-
}
|
|
475
|
+
await this._enterCurrentStepWithErrorHandling("start");
|
|
376
476
|
}
|
|
377
477
|
async _nextAsync(active) {
|
|
378
|
-
if (this.
|
|
478
|
+
if (this._status !== "idle")
|
|
379
479
|
return;
|
|
380
480
|
// Record that the user has attempted to advance — used by shells and step
|
|
381
481
|
// templates to gate error display ("punish late, reward early").
|
|
382
482
|
this._hasAttemptedNext = true;
|
|
383
|
-
|
|
483
|
+
// Phase: validating — canMoveNext guard
|
|
484
|
+
this._status = "validating";
|
|
384
485
|
this.emitStateChanged("next");
|
|
486
|
+
let guardResult;
|
|
385
487
|
try {
|
|
386
|
-
|
|
387
|
-
if (await this.canMoveNext(active, step)) {
|
|
388
|
-
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
389
|
-
active.currentStepIndex += 1;
|
|
390
|
-
await this.skipSteps(1);
|
|
391
|
-
if (active.currentStepIndex >= active.definition.steps.length) {
|
|
392
|
-
this._isNavigating = false;
|
|
393
|
-
await this.finishActivePath();
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
397
|
-
}
|
|
398
|
-
this._isNavigating = false;
|
|
399
|
-
this.emitStateChanged("next");
|
|
488
|
+
guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
|
|
400
489
|
}
|
|
401
490
|
catch (err) {
|
|
402
|
-
this.
|
|
491
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
|
|
492
|
+
this._pendingRetry = () => this._nextAsync(active);
|
|
493
|
+
this._status = "error";
|
|
403
494
|
this.emitStateChanged("next");
|
|
404
|
-
|
|
495
|
+
return;
|
|
405
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;
|
|
521
|
+
}
|
|
522
|
+
this._blockingError = guardResult.reason;
|
|
523
|
+
this._status = "idle";
|
|
524
|
+
this.emitStateChanged("next");
|
|
406
525
|
}
|
|
407
526
|
async _previousAsync(active) {
|
|
408
|
-
if (this.
|
|
527
|
+
if (this._status !== "idle")
|
|
409
528
|
return;
|
|
410
529
|
// No-op when already on the first step of a top-level path.
|
|
411
530
|
// Sub-paths still cancel/pop back to the parent when previous() is called
|
|
412
531
|
// on their first step (the currentStepIndex < 0 branch below handles that).
|
|
413
532
|
if (active.currentStepIndex === 0 && this.pathStack.length === 0)
|
|
414
533
|
return;
|
|
415
|
-
this.
|
|
534
|
+
this._status = "leaving";
|
|
416
535
|
this.emitStateChanged("previous");
|
|
417
536
|
try {
|
|
418
537
|
const step = this.getEffectiveStep(active);
|
|
419
|
-
|
|
538
|
+
const prevGuard = await this.canMovePrevious(active, step);
|
|
539
|
+
if (!prevGuard.allowed)
|
|
540
|
+
this._blockingError = prevGuard.reason;
|
|
541
|
+
if (prevGuard.allowed) {
|
|
420
542
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
421
543
|
active.currentStepIndex -= 1;
|
|
422
544
|
await this.skipSteps(-1);
|
|
423
545
|
if (active.currentStepIndex < 0) {
|
|
424
|
-
this.
|
|
546
|
+
this._status = "idle";
|
|
425
547
|
await this.cancel();
|
|
426
548
|
return;
|
|
427
549
|
}
|
|
428
550
|
this.applyPatch(await this.enterCurrentStep());
|
|
429
551
|
}
|
|
430
|
-
this.
|
|
552
|
+
this._status = "idle";
|
|
431
553
|
this.emitStateChanged("previous");
|
|
432
554
|
}
|
|
433
555
|
catch (err) {
|
|
434
|
-
this.
|
|
556
|
+
this._status = "idle";
|
|
435
557
|
this.emitStateChanged("previous");
|
|
436
558
|
throw err;
|
|
437
559
|
}
|
|
438
560
|
}
|
|
439
561
|
async _goToStepAsync(active, targetIndex) {
|
|
440
|
-
if (this.
|
|
562
|
+
if (this._status !== "idle")
|
|
441
563
|
return;
|
|
442
|
-
this.
|
|
564
|
+
this._status = "leaving";
|
|
443
565
|
this.emitStateChanged("goToStep");
|
|
444
566
|
try {
|
|
445
567
|
const currentStep = this.getEffectiveStep(active);
|
|
446
568
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
447
569
|
active.currentStepIndex = targetIndex;
|
|
448
570
|
this.applyPatch(await this.enterCurrentStep());
|
|
449
|
-
this.
|
|
571
|
+
this._status = "idle";
|
|
450
572
|
this.emitStateChanged("goToStep");
|
|
451
573
|
}
|
|
452
574
|
catch (err) {
|
|
453
|
-
this.
|
|
575
|
+
this._status = "idle";
|
|
454
576
|
this.emitStateChanged("goToStep");
|
|
455
577
|
throw err;
|
|
456
578
|
}
|
|
457
579
|
}
|
|
458
580
|
async _goToStepCheckedAsync(active, targetIndex) {
|
|
459
|
-
if (this.
|
|
581
|
+
if (this._status !== "idle")
|
|
460
582
|
return;
|
|
461
|
-
this.
|
|
583
|
+
this._status = "validating";
|
|
462
584
|
this.emitStateChanged("goToStepChecked");
|
|
463
585
|
try {
|
|
464
586
|
const currentStep = this.getEffectiveStep(active);
|
|
465
587
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
466
|
-
const
|
|
588
|
+
const guardResult = goingForward
|
|
467
589
|
? await this.canMoveNext(active, currentStep)
|
|
468
590
|
: await this.canMovePrevious(active, currentStep);
|
|
469
|
-
if (allowed)
|
|
591
|
+
if (!guardResult.allowed)
|
|
592
|
+
this._blockingError = guardResult.reason;
|
|
593
|
+
if (guardResult.allowed) {
|
|
594
|
+
this._status = "leaving";
|
|
470
595
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
471
596
|
active.currentStepIndex = targetIndex;
|
|
472
597
|
this.applyPatch(await this.enterCurrentStep());
|
|
473
598
|
}
|
|
474
|
-
this.
|
|
599
|
+
this._status = "idle";
|
|
475
600
|
this.emitStateChanged("goToStepChecked");
|
|
476
601
|
}
|
|
477
602
|
catch (err) {
|
|
478
|
-
this.
|
|
603
|
+
this._status = "idle";
|
|
479
604
|
this.emitStateChanged("goToStepChecked");
|
|
480
605
|
throw err;
|
|
481
606
|
}
|
|
@@ -485,7 +610,7 @@ export class PathEngine {
|
|
|
485
610
|
// path (which has a valid currentStepIndex) rather than the cancelled
|
|
486
611
|
// sub-path (which may have currentStepIndex = -1).
|
|
487
612
|
this.activePath = this.pathStack.pop() ?? null;
|
|
488
|
-
this.
|
|
613
|
+
this._status = "leaving";
|
|
489
614
|
this.emitStateChanged("cancel");
|
|
490
615
|
try {
|
|
491
616
|
const parent = this.activePath;
|
|
@@ -502,11 +627,11 @@ export class PathEngine {
|
|
|
502
627
|
this.applyPatch(await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta));
|
|
503
628
|
}
|
|
504
629
|
}
|
|
505
|
-
this.
|
|
630
|
+
this._status = "idle";
|
|
506
631
|
this.emitStateChanged("cancel");
|
|
507
632
|
}
|
|
508
633
|
catch (err) {
|
|
509
|
-
this.
|
|
634
|
+
this._status = "idle";
|
|
510
635
|
this.emitStateChanged("cancel");
|
|
511
636
|
throw err;
|
|
512
637
|
}
|
|
@@ -539,17 +664,75 @@ export class PathEngine {
|
|
|
539
664
|
});
|
|
540
665
|
}
|
|
541
666
|
else {
|
|
542
|
-
// Top-level path completed — call onComplete
|
|
543
|
-
|
|
544
|
-
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.
|
|
545
669
|
if (finished.definition.onComplete) {
|
|
546
670
|
await finished.definition.onComplete(finishedData);
|
|
547
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);
|
|
548
728
|
}
|
|
549
729
|
}
|
|
550
730
|
// ---------------------------------------------------------------------------
|
|
551
731
|
// Private helpers
|
|
552
732
|
// ---------------------------------------------------------------------------
|
|
733
|
+
static errorMessage(err) {
|
|
734
|
+
return err instanceof Error ? err.message : String(err);
|
|
735
|
+
}
|
|
553
736
|
requireActivePath() {
|
|
554
737
|
if (this.activePath === null) {
|
|
555
738
|
throw new Error("No active path.");
|
|
@@ -636,23 +819,41 @@ export class PathEngine {
|
|
|
636
819
|
while (active.currentStepIndex >= 0 &&
|
|
637
820
|
active.currentStepIndex < active.definition.steps.length) {
|
|
638
821
|
const item = active.definition.steps[active.currentStepIndex];
|
|
639
|
-
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);
|
|
640
826
|
break;
|
|
827
|
+
}
|
|
641
828
|
const ctx = {
|
|
642
829
|
pathId: active.definition.id,
|
|
643
830
|
stepId: item.id,
|
|
644
831
|
data: { ...active.data },
|
|
645
832
|
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
646
833
|
};
|
|
647
|
-
const
|
|
648
|
-
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);
|
|
649
847
|
break;
|
|
848
|
+
}
|
|
849
|
+
active.resolvedSkips.add(item.id);
|
|
650
850
|
active.currentStepIndex += direction;
|
|
651
851
|
}
|
|
652
852
|
}
|
|
653
853
|
async enterCurrentStep() {
|
|
654
854
|
// Each step starts fresh — errors are not shown until the user attempts to proceed.
|
|
655
855
|
this._hasAttemptedNext = false;
|
|
856
|
+
this._blockingError = null;
|
|
656
857
|
const active = this.activePath;
|
|
657
858
|
if (!active)
|
|
658
859
|
return;
|
|
@@ -697,23 +898,31 @@ export class PathEngine {
|
|
|
697
898
|
data: { ...active.data },
|
|
698
899
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
699
900
|
};
|
|
700
|
-
|
|
901
|
+
const result = await step.canMoveNext(ctx);
|
|
902
|
+
return PathEngine.normaliseGuardResult(result);
|
|
701
903
|
}
|
|
702
904
|
if (step.fieldErrors) {
|
|
703
|
-
|
|
905
|
+
const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
906
|
+
return { allowed, reason: null };
|
|
704
907
|
}
|
|
705
|
-
return true;
|
|
908
|
+
return { allowed: true, reason: null };
|
|
706
909
|
}
|
|
707
910
|
async canMovePrevious(active, step) {
|
|
708
911
|
if (!step.canMovePrevious)
|
|
709
|
-
return true;
|
|
912
|
+
return { allowed: true, reason: null };
|
|
710
913
|
const ctx = {
|
|
711
914
|
pathId: active.definition.id,
|
|
712
915
|
stepId: step.id,
|
|
713
916
|
data: { ...active.data },
|
|
714
917
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
715
918
|
};
|
|
716
|
-
|
|
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 };
|
|
717
926
|
}
|
|
718
927
|
/**
|
|
719
928
|
* Evaluates a guard function synchronously for inclusion in the snapshot.
|
|
@@ -741,16 +950,19 @@ export class PathEngine {
|
|
|
741
950
|
};
|
|
742
951
|
try {
|
|
743
952
|
const result = guard(ctx);
|
|
744
|
-
if (
|
|
745
|
-
return
|
|
746
|
-
// Async guard detected - warn and return optimistic default
|
|
953
|
+
if (result === true)
|
|
954
|
+
return true;
|
|
747
955
|
if (result && typeof result.then === "function") {
|
|
956
|
+
// Async guard detected - suppress the unhandled rejection, warn, return optimistic default
|
|
957
|
+
result.catch(() => { });
|
|
748
958
|
console.warn(`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
749
959
|
`Guards in snapshots must be synchronous. ` +
|
|
750
960
|
`Returning true (optimistic) as default. ` +
|
|
751
961
|
`The async guard will still be enforced during actual navigation.`);
|
|
962
|
+
return true;
|
|
752
963
|
}
|
|
753
|
-
|
|
964
|
+
// { allowed: false, reason? } object returned synchronously
|
|
965
|
+
return false;
|
|
754
966
|
}
|
|
755
967
|
catch (err) {
|
|
756
968
|
console.warn(`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|