@player-ui/player 0.15.3 → 0.15.4--canary.881.37421
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/Player.native.js +3259 -2768
- package/dist/Player.native.js.map +1 -1
- package/dist/cjs/index.cjs +2553 -2114
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.legacy-esm.js +2535 -2103
- package/dist/index.mjs +2535 -2103
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/data.test.ts +0 -13
- package/src/__tests__/view.test.ts +34 -1
- package/src/controllers/data/controller.ts +1 -1
- package/src/controllers/data/utils.ts +5 -26
- package/src/controllers/error/__tests__/controller.test.ts +359 -0
- package/src/controllers/error/__tests__/middleware.test.ts +237 -0
- package/src/controllers/error/__tests__/navigation.test.ts +190 -0
- package/src/controllers/error/controller.ts +257 -0
- package/src/controllers/error/index.ts +3 -0
- package/src/controllers/error/middleware.ts +106 -0
- package/src/controllers/error/types.ts +42 -0
- package/src/controllers/error/utils/__tests__/isErrorWithMetadata.test.ts +114 -0
- package/src/controllers/error/utils/__tests__/makeJsonStringifyReplacer.test.ts +24 -0
- package/src/controllers/error/utils/index.ts +2 -0
- package/src/controllers/error/utils/isErrorWithMetadata.ts +28 -0
- package/src/controllers/error/utils/makeJsonStringifyReplacer.ts +17 -0
- package/src/controllers/flow/__tests__/flow.test.ts +268 -0
- package/src/controllers/flow/flow.ts +96 -4
- package/src/controllers/index.ts +1 -0
- package/src/controllers/view/controller.ts +22 -3
- package/src/data/model.ts +6 -0
- package/src/expressions/types.ts +8 -4
- package/src/player.ts +20 -1
- package/src/types.ts +6 -0
- package/src/validator/types.ts +2 -1
- package/src/view/parser/types.ts +6 -3
- package/src/view/plugins/__tests__/template.test.ts +7 -2
- package/src/view/resolver/ResolverError.ts +25 -0
- package/src/view/resolver/__tests__/index.test.ts +53 -1
- package/src/view/resolver/index.ts +68 -37
- package/src/view/resolver/types.ts +13 -0
- package/src/view/resolver/utils.ts +1 -1
- package/types/controllers/data/utils.d.ts +3 -7
- package/types/controllers/error/controller.d.ts +82 -0
- package/types/controllers/error/index.d.ts +4 -0
- package/types/controllers/error/middleware.d.ts +23 -0
- package/types/controllers/error/types.d.ts +35 -0
- package/types/controllers/error/utils/index.d.ts +3 -0
- package/types/controllers/error/utils/isErrorWithMetadata.d.ts +3 -0
- package/types/controllers/error/utils/makeJsonStringifyReplacer.d.ts +5 -0
- package/types/controllers/flow/flow.d.ts +17 -0
- package/types/controllers/index.d.ts +1 -0
- package/types/controllers/view/controller.d.ts +4 -0
- package/types/data/model.d.ts +5 -0
- package/types/types.d.ts +5 -1
- package/types/view/resolver/ResolverError.d.ts +13 -0
- package/types/view/resolver/index.d.ts +2 -1
- package/types/view/resolver/types.d.ts +11 -0
|
@@ -150,10 +150,98 @@ export class FlowInstance {
|
|
|
150
150
|
return this.flowPromise.promise;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Get the flow-level error transitions map
|
|
155
|
+
*/
|
|
156
|
+
public getFlowErrorTransitions(): Record<string, string> | undefined {
|
|
157
|
+
return this.flow.errorTransitions;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Helper to lookup a key in a map with wildcard fallback
|
|
162
|
+
*/
|
|
163
|
+
private lookupInMap(
|
|
164
|
+
map: Record<string, string> | undefined,
|
|
165
|
+
key: string,
|
|
166
|
+
): string | undefined {
|
|
167
|
+
if (!map) return undefined;
|
|
168
|
+
return map[key] || map["*"];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Check if the flow has a transition for the given error type in its current state. */
|
|
172
|
+
public getErrorTransitionState(errorType: string): string | undefined {
|
|
173
|
+
// Can't navigate from END state
|
|
174
|
+
if (this.currentState?.value.state_type === "END") {
|
|
175
|
+
this.log?.warn("Cannot error transition from END state");
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Try node-level errorTransitions (only if we have a current state)
|
|
180
|
+
if (this.currentState) {
|
|
181
|
+
const nodeState = this.lookupInMap(
|
|
182
|
+
this.currentState.value.errorTransitions,
|
|
183
|
+
errorType,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (nodeState) {
|
|
187
|
+
if (!Object.prototype.hasOwnProperty.call(this.flow, nodeState)) {
|
|
188
|
+
this.log?.debug(
|
|
189
|
+
`Node-level errorTransition references non-existent state "${nodeState}", trying flow-level fallback`,
|
|
190
|
+
);
|
|
191
|
+
// Fall through to try flow-level
|
|
192
|
+
} else {
|
|
193
|
+
this.log?.debug(
|
|
194
|
+
`Error transition (node-level) from ${this.currentState.name} to ${nodeState} using ${errorType}`,
|
|
195
|
+
);
|
|
196
|
+
return nodeState;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Try flow-level errorTransitions
|
|
202
|
+
const flowState = this.lookupInMap(this.flow.errorTransitions, errorType);
|
|
203
|
+
|
|
204
|
+
if (flowState) {
|
|
205
|
+
// Validate state exists before navigating
|
|
206
|
+
if (!Object.prototype.hasOwnProperty.call(this.flow, flowState)) {
|
|
207
|
+
this.log?.debug(
|
|
208
|
+
`Flow-level errorTransition references non-existent state "${flowState}"`,
|
|
209
|
+
);
|
|
210
|
+
// No valid transition found, will warn below
|
|
211
|
+
} else {
|
|
212
|
+
this.log?.debug(
|
|
213
|
+
`Error transition (flow-level) to ${flowState} using ${errorType}${this.currentState ? ` from ${this.currentState.name}` : ""}`,
|
|
214
|
+
);
|
|
215
|
+
return flowState;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Navigate using errorTransitions map.
|
|
224
|
+
* Tries node-level first, then falls back to flow-level.
|
|
225
|
+
* Bypasses validation hooks and expression resolution.
|
|
226
|
+
* @throws Error if errorTransitions references a non-existent state
|
|
227
|
+
*/
|
|
228
|
+
public errorTransition(errorType: string): void {
|
|
229
|
+
const transitionState = this.getErrorTransitionState(errorType);
|
|
230
|
+
if (transitionState === undefined) {
|
|
231
|
+
this.log?.warn(
|
|
232
|
+
`No errorTransition found for ${errorType} (checked node and flow level)`,
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.pushHistory(transitionState);
|
|
238
|
+
}
|
|
239
|
+
|
|
153
240
|
public transition(
|
|
154
241
|
transitionValue: string,
|
|
155
242
|
options?: TransitionOptions,
|
|
156
243
|
): void {
|
|
244
|
+
// Check if we can transition
|
|
157
245
|
if (this.isTransitioning) {
|
|
158
246
|
throw new Error(
|
|
159
247
|
`Transitioning while ongoing transition from ${this.currentState?.name} is in progress is not supported`,
|
|
@@ -162,9 +250,8 @@ export class FlowInstance {
|
|
|
162
250
|
|
|
163
251
|
if (this.currentState?.value.state_type === "END") {
|
|
164
252
|
this.log?.warn(
|
|
165
|
-
`Skipping transition using ${transitionValue}. Already at
|
|
253
|
+
`Skipping transition using ${transitionValue}. Already at END state`,
|
|
166
254
|
);
|
|
167
|
-
|
|
168
255
|
return;
|
|
169
256
|
}
|
|
170
257
|
|
|
@@ -172,6 +259,9 @@ export class FlowInstance {
|
|
|
172
259
|
throw new Error("Cannot transition when there's no current state");
|
|
173
260
|
}
|
|
174
261
|
|
|
262
|
+
const currentState = this.currentState.value;
|
|
263
|
+
|
|
264
|
+
// For normal transitions: use hooks
|
|
175
265
|
if (options?.force) {
|
|
176
266
|
this.log?.debug(`Forced transition. Skipping validation checks`);
|
|
177
267
|
} else {
|
|
@@ -186,7 +276,7 @@ export class FlowInstance {
|
|
|
186
276
|
}
|
|
187
277
|
|
|
188
278
|
const state = this.hooks.beforeTransition.call(
|
|
189
|
-
|
|
279
|
+
currentState as Exclude<NavigationFlowState, NavigationFlowEndState>,
|
|
190
280
|
transitionValue,
|
|
191
281
|
);
|
|
192
282
|
|
|
@@ -232,7 +322,9 @@ export class FlowInstance {
|
|
|
232
322
|
const prevState = this.currentState;
|
|
233
323
|
|
|
234
324
|
this.isTransitioning = true;
|
|
235
|
-
nextState = this.hooks.resolveTransitionNode.call(
|
|
325
|
+
nextState = this.hooks.resolveTransitionNode.call(
|
|
326
|
+
nextState as NavigationFlowState,
|
|
327
|
+
);
|
|
236
328
|
|
|
237
329
|
const newCurrentState = {
|
|
238
330
|
name: stateName,
|
package/src/controllers/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { DataController } from "../data/controller";
|
|
|
22
22
|
import type { TransformRegistry } from "./types";
|
|
23
23
|
import type { BindingInstance } from "../../binding";
|
|
24
24
|
import type { Node } from "../../view";
|
|
25
|
+
import { ErrorController } from "../error";
|
|
25
26
|
|
|
26
27
|
export interface ViewControllerOptions {
|
|
27
28
|
/** Where to get data from */
|
|
@@ -32,6 +33,9 @@ export interface ViewControllerOptions {
|
|
|
32
33
|
|
|
33
34
|
/** A flow-controller instance to listen for view changes */
|
|
34
35
|
flowController: FlowController;
|
|
36
|
+
|
|
37
|
+
/** Error controller to use when managing view-level errors */
|
|
38
|
+
errorController: ErrorController;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export type ViewControllerHooks = {
|
|
@@ -104,7 +108,7 @@ export class ViewController {
|
|
|
104
108
|
if (this.optimizeUpdates) {
|
|
105
109
|
this.queueUpdate(updates, undefined, silent);
|
|
106
110
|
} else {
|
|
107
|
-
this.
|
|
111
|
+
this.updateView();
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
};
|
|
@@ -158,11 +162,26 @@ export class ViewController {
|
|
|
158
162
|
queueMicrotask(() => {
|
|
159
163
|
const { changedBindings, changedNodes } = this.pendingUpdate ?? {};
|
|
160
164
|
this.pendingUpdate = undefined;
|
|
161
|
-
this.
|
|
165
|
+
this.updateView(changedBindings, changedNodes);
|
|
162
166
|
});
|
|
163
167
|
}
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
private updateView(
|
|
171
|
+
changedBindings?: Set<BindingInstance>,
|
|
172
|
+
changedNodes?: Set<Node.Node>,
|
|
173
|
+
) {
|
|
174
|
+
try {
|
|
175
|
+
this.currentView?.update(changedBindings, changedNodes);
|
|
176
|
+
} catch (exception: unknown) {
|
|
177
|
+
const err =
|
|
178
|
+
exception instanceof Error ? exception : new Error(String(exception));
|
|
179
|
+
// Can't assume any node or binding changes were consumed correctly during the update, so trigger a silent update to ensure that any additional update triggered to recover still updates everything.
|
|
180
|
+
this.queueUpdate(changedBindings, changedNodes, true);
|
|
181
|
+
this.viewOptions.errorController.captureError(err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
166
185
|
private getViewForRef(viewRef: string): View | undefined {
|
|
167
186
|
// First look for a 1:1 viewRef -> id mapping (this is most common)
|
|
168
187
|
if (this.viewMap[viewRef]) {
|
|
@@ -204,7 +223,7 @@ export class ViewController {
|
|
|
204
223
|
// own listeners to the view before we resolve it
|
|
205
224
|
this.applyViewPlugins(view);
|
|
206
225
|
this.hooks.view.call(view);
|
|
207
|
-
|
|
226
|
+
this.updateView();
|
|
208
227
|
}
|
|
209
228
|
|
|
210
229
|
private applyViewPlugins(view: ViewInstance): void {
|
package/src/data/model.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface DataModelOptions {
|
|
|
46
46
|
*/
|
|
47
47
|
silent?: boolean;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Write authorization symbol for internal middleware operations
|
|
51
|
+
* Middleware can use this to verify the caller has permission for write operations
|
|
52
|
+
*/
|
|
53
|
+
writeSymbol?: symbol;
|
|
54
|
+
|
|
49
55
|
/** Other context associated with this request */
|
|
50
56
|
context?: {
|
|
51
57
|
/** The data model to use when getting other data from the context of this request */
|
package/src/expressions/types.ts
CHANGED
|
@@ -129,13 +129,15 @@ export interface LiteralNode extends BaseNode<"Literal"> {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
export interface BinaryNode
|
|
132
|
-
extends BaseNode<"BinaryExpression">,
|
|
132
|
+
extends BaseNode<"BinaryExpression">,
|
|
133
|
+
DirectionalNode {
|
|
133
134
|
/** The operation to perform on the nodes */
|
|
134
135
|
operator: string;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
export interface LogicalNode
|
|
138
|
-
extends BaseNode<"LogicalExpression">,
|
|
139
|
+
extends BaseNode<"LogicalExpression">,
|
|
140
|
+
DirectionalNode {
|
|
139
141
|
/** The logical operation to perform on the nodes */
|
|
140
142
|
operator: string;
|
|
141
143
|
}
|
|
@@ -177,7 +179,8 @@ export interface MemberExpressionNode extends BaseNode<"MemberExpression"> {
|
|
|
177
179
|
property: ExpressionNode;
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
export interface ConditionalExpressionNode
|
|
182
|
+
export interface ConditionalExpressionNode
|
|
183
|
+
extends BaseNode<"ConditionalExpression"> {
|
|
181
184
|
/** The test for the ternary */
|
|
182
185
|
test: ExpressionNode;
|
|
183
186
|
|
|
@@ -214,7 +217,8 @@ export interface IdentifierNode extends BaseNode<"Identifier"> {
|
|
|
214
217
|
export type AssignmentNode = BaseNode<"Assignment"> & DirectionalNode;
|
|
215
218
|
|
|
216
219
|
export interface ModificationNode
|
|
217
|
-
extends BaseNode<"Modification">,
|
|
220
|
+
extends BaseNode<"Modification">,
|
|
221
|
+
DirectionalNode {
|
|
218
222
|
/** The operator for the modification */
|
|
219
223
|
operator: string;
|
|
220
224
|
}
|
package/src/player.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DataController,
|
|
20
20
|
ValidationController,
|
|
21
21
|
FlowController,
|
|
22
|
+
ErrorController,
|
|
22
23
|
} from "./controllers";
|
|
23
24
|
import { FlowExpPlugin } from "./plugins/flow-exp-plugin";
|
|
24
25
|
import { DefaultExpPlugin } from "./plugins/default-exp-plugin";
|
|
@@ -119,6 +120,7 @@ export class Player {
|
|
|
119
120
|
schema: new SyncHook<[SchemaController]>(),
|
|
120
121
|
validationController: new SyncHook<[ValidationController]>(),
|
|
121
122
|
bindingParser: new SyncHook<[BindingParser]>(),
|
|
123
|
+
errorController: new SyncHook<[ErrorController]>(),
|
|
122
124
|
state: new SyncHook<[PlayerFlowState]>(),
|
|
123
125
|
onStart: new SyncHook<[Flow]>(),
|
|
124
126
|
onEnd: new SyncHook<[]>(),
|
|
@@ -245,9 +247,20 @@ export class Player {
|
|
|
245
247
|
|
|
246
248
|
this.hooks.validationController.call(validationController);
|
|
247
249
|
|
|
250
|
+
const errorController = new ErrorController({
|
|
251
|
+
logger: this.logger,
|
|
252
|
+
flow: flowController,
|
|
253
|
+
fail: flowResultDeferred.reject,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.hooks.errorController.call(errorController);
|
|
257
|
+
|
|
248
258
|
dataController = new DataController(userFlow.data, {
|
|
249
259
|
pathResolver,
|
|
250
|
-
middleware:
|
|
260
|
+
middleware: [
|
|
261
|
+
...validationController.getDataMiddleware(),
|
|
262
|
+
errorController.getDataMiddleware(),
|
|
263
|
+
],
|
|
251
264
|
logger: this.logger,
|
|
252
265
|
});
|
|
253
266
|
|
|
@@ -268,6 +281,10 @@ export class Player {
|
|
|
268
281
|
(binding) => schema.getApparentType(binding)?.default,
|
|
269
282
|
);
|
|
270
283
|
|
|
284
|
+
errorController.setOptions({
|
|
285
|
+
model: dataController,
|
|
286
|
+
});
|
|
287
|
+
|
|
271
288
|
// eslint-disable-next-line prefer-const
|
|
272
289
|
let viewController: ViewController;
|
|
273
290
|
|
|
@@ -447,6 +464,7 @@ export class Player {
|
|
|
447
464
|
type: (b) => schema.getType(parseBinding(b)),
|
|
448
465
|
},
|
|
449
466
|
constants: this.constantsController,
|
|
467
|
+
errorController,
|
|
450
468
|
});
|
|
451
469
|
|
|
452
470
|
viewController.hooks.view.tap("player", (view) => {
|
|
@@ -486,6 +504,7 @@ export class Player {
|
|
|
486
504
|
expression: expressionEvaluator,
|
|
487
505
|
binding: pathResolver,
|
|
488
506
|
validation: validationController,
|
|
507
|
+
error: errorController,
|
|
489
508
|
},
|
|
490
509
|
fail: flowResultDeferred.reject,
|
|
491
510
|
flow: userFlow,
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
DataController,
|
|
9
9
|
ValidationController,
|
|
10
10
|
FlowController,
|
|
11
|
+
ErrorController,
|
|
11
12
|
} from "./controllers";
|
|
12
13
|
import type { ReadOnlyDataController } from "./controllers/data/utils";
|
|
13
14
|
import { SyncHook, SyncWaterfallHook } from "tapable-ts";
|
|
@@ -33,6 +34,8 @@ export interface PlayerHooks {
|
|
|
33
34
|
validationController: SyncHook<[ValidationController], Record<string, any>>;
|
|
34
35
|
/** Manages parsing binding */
|
|
35
36
|
bindingParser: SyncHook<[BindingParser], Record<string, any>>;
|
|
37
|
+
/** Manages error handling and captures errors from all subsystems */
|
|
38
|
+
errorController: SyncHook<[ErrorController], Record<string, any>>;
|
|
36
39
|
/** A that's called for state changes in the flow execution */
|
|
37
40
|
state: SyncHook<[PlayerFlowState], Record<string, any>>;
|
|
38
41
|
/** A hook to access the current flow */
|
|
@@ -97,6 +100,9 @@ export interface ControllerState {
|
|
|
97
100
|
|
|
98
101
|
/** the manager for the flow state machine */
|
|
99
102
|
flow: FlowController;
|
|
103
|
+
|
|
104
|
+
/** The manager for error handling */
|
|
105
|
+
error: ErrorController;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
/** A flow is currently executing */
|
package/src/validator/types.ts
CHANGED
|
@@ -23,7 +23,8 @@ interface BaseValidationResponse<T = Validation.Severity> {
|
|
|
23
23
|
blocking?: boolean | "once";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export interface WarningValidationResponse
|
|
26
|
+
export interface WarningValidationResponse
|
|
27
|
+
extends BaseValidationResponse<"warning"> {
|
|
27
28
|
/** Warning validations can be dismissed without correcting the error */
|
|
28
29
|
dismiss?: () => void;
|
|
29
30
|
}
|
package/src/view/parser/types.ts
CHANGED
|
@@ -43,13 +43,15 @@ export declare namespace Node {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Asset<T extends AnyAssetType = AnyAssetType>
|
|
46
|
-
extends BaseWithChildren<NodeType.Asset>,
|
|
46
|
+
extends BaseWithChildren<NodeType.Asset>,
|
|
47
|
+
PluginOptions {
|
|
47
48
|
/** Any asset nested within a view */
|
|
48
49
|
value: T;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface View<T extends AnyAssetType = AnyAssetType>
|
|
52
|
-
extends BaseWithChildren<NodeType.View>,
|
|
53
|
+
extends BaseWithChildren<NodeType.View>,
|
|
54
|
+
PluginOptions {
|
|
53
55
|
/** The root of the parsed view */
|
|
54
56
|
value: T;
|
|
55
57
|
}
|
|
@@ -80,7 +82,8 @@ export declare namespace Node {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
export interface Value
|
|
83
|
-
extends BaseWithChildren<NodeType.Value>,
|
|
85
|
+
extends BaseWithChildren<NodeType.Value>,
|
|
86
|
+
PluginOptions {
|
|
84
87
|
/** A simple node representing a value */
|
|
85
88
|
value: any;
|
|
86
89
|
}
|
|
@@ -7,8 +7,13 @@ import { SchemaController } from "../../../schema";
|
|
|
7
7
|
import { Parser } from "../../parser";
|
|
8
8
|
import { ViewInstance } from "../../view";
|
|
9
9
|
import type { Options } from "../options";
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
TemplatePlugin,
|
|
12
|
+
MultiNodePlugin,
|
|
13
|
+
AssetPlugin,
|
|
14
|
+
StringResolverPlugin,
|
|
15
|
+
} from "../";
|
|
16
|
+
import { toNodeResolveOptions } from "../../resolver";
|
|
12
17
|
import type { View } from "@player-ui/types";
|
|
13
18
|
|
|
14
19
|
const templateJoinValues = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Node } from "../parser";
|
|
2
|
+
import {
|
|
3
|
+
ErrorSeverity,
|
|
4
|
+
ErrorTypes,
|
|
5
|
+
type PlayerErrorMetadata,
|
|
6
|
+
} from "../../controllers";
|
|
7
|
+
import type { ResolverErrorMetadata, ResolverStage } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Error class to represent errors in the player resolver. */
|
|
10
|
+
export class ResolverError extends Error implements PlayerErrorMetadata {
|
|
11
|
+
readonly type: string = ErrorTypes.VIEW;
|
|
12
|
+
readonly severity: ErrorSeverity = ErrorSeverity.ERROR;
|
|
13
|
+
readonly metadata: ResolverErrorMetadata;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly cause: unknown,
|
|
17
|
+
public readonly stage: ResolverStage,
|
|
18
|
+
node: Node.Node,
|
|
19
|
+
) {
|
|
20
|
+
super(`An error in the resolver occurred at stage '${stage}'`);
|
|
21
|
+
this.metadata = {
|
|
22
|
+
node,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -3,7 +3,7 @@ import { BindingParser } from "../../../binding";
|
|
|
3
3
|
import { ExpressionEvaluator } from "../../../expressions";
|
|
4
4
|
import { LocalModel, withParser } from "../../../data";
|
|
5
5
|
import { SchemaController } from "../../../schema";
|
|
6
|
-
import { Resolve, Resolver } from "..";
|
|
6
|
+
import { Resolve, Resolver, ResolverError } from "..";
|
|
7
7
|
import type { Node } from "../../parser";
|
|
8
8
|
import { NodeType, Parser } from "../../parser";
|
|
9
9
|
|
|
@@ -137,3 +137,55 @@ describe("Node cache updates", () => {
|
|
|
137
137
|
);
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("error handling", () => {
|
|
142
|
+
let resolverOptions: Resolve.ResolverOptions;
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
const model = new LocalModel({});
|
|
146
|
+
const parser = new Parser();
|
|
147
|
+
const bindingParser = new BindingParser();
|
|
148
|
+
|
|
149
|
+
resolverOptions = {
|
|
150
|
+
model,
|
|
151
|
+
parseBinding: bindingParser.parse.bind(bindingParser),
|
|
152
|
+
parseNode: parser.parseObject.bind(parser),
|
|
153
|
+
evaluator: new ExpressionEvaluator({
|
|
154
|
+
model: withParser(model, bindingParser.parse),
|
|
155
|
+
}),
|
|
156
|
+
schema: new SchemaController(),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const computeTreeHooks: Array<keyof Resolver["hooks"]> = [
|
|
161
|
+
"afterNodeUpdate",
|
|
162
|
+
"afterResolve",
|
|
163
|
+
"beforeResolve",
|
|
164
|
+
"resolve",
|
|
165
|
+
"resolveOptions",
|
|
166
|
+
"skipResolve",
|
|
167
|
+
];
|
|
168
|
+
it.each(computeTreeHooks)(
|
|
169
|
+
"should wrap errors in hooks in a ResolverError",
|
|
170
|
+
(hook) => {
|
|
171
|
+
const resolver = new Resolver(simpleViewWithAsync, resolverOptions);
|
|
172
|
+
|
|
173
|
+
resolver.hooks[hook].tap("test", () => {
|
|
174
|
+
throw new Error("ERROR!");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let error: unknown;
|
|
178
|
+
try {
|
|
179
|
+
resolver.update();
|
|
180
|
+
} catch (err: unknown) {
|
|
181
|
+
error = err;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(error).toBeDefined();
|
|
185
|
+
expect(error).toBeInstanceOf(ResolverError);
|
|
186
|
+
const resolverError = error as ResolverError;
|
|
187
|
+
expect(resolverError.cause).toStrictEqual(new Error("ERROR!"));
|
|
188
|
+
expect(resolverError.stage).toStrictEqual(hook);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
@@ -12,11 +12,13 @@ import { DependencyModel, withParser } from "../../data";
|
|
|
12
12
|
import type { Logger } from "../../logger";
|
|
13
13
|
import { Node, NodeType } from "../parser";
|
|
14
14
|
import { caresAboutDataChanges, toNodeResolveOptions } from "./utils";
|
|
15
|
-
import type
|
|
15
|
+
import { ResolverStage, type Resolve } from "./types";
|
|
16
16
|
import { getNodeID } from "../parser/utils";
|
|
17
|
+
import { ResolverError } from "./ResolverError";
|
|
17
18
|
|
|
18
19
|
export * from "./types";
|
|
19
20
|
export * from "./utils";
|
|
21
|
+
export * from "./ResolverError";
|
|
20
22
|
|
|
21
23
|
interface NodeUpdate extends Resolve.ResolvedNode {
|
|
22
24
|
/** A flag to track if a node has changed since the last resolution */
|
|
@@ -252,36 +254,44 @@ export class Resolver {
|
|
|
252
254
|
nodeChanges: Set<Node.Node>,
|
|
253
255
|
): NodeUpdate {
|
|
254
256
|
const dependencyModel = new DependencyModel(options.data.model);
|
|
255
|
-
|
|
256
257
|
dependencyModel.trackSubset("core");
|
|
257
258
|
const depModelWithParser = withContext(
|
|
258
259
|
withParser(dependencyModel, this.options.parseBinding),
|
|
259
260
|
);
|
|
260
261
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
data
|
|
265
|
-
|
|
266
|
-
model: depModelWithParser,
|
|
267
|
-
},
|
|
268
|
-
evaluate: (exp) =>
|
|
269
|
-
this.options.evaluator.evaluate(exp, { model: depModelWithParser }),
|
|
270
|
-
node,
|
|
262
|
+
let resolveOptions: Resolve.NodeResolveOptions = {
|
|
263
|
+
...options,
|
|
264
|
+
data: {
|
|
265
|
+
...options.data,
|
|
266
|
+
model: depModelWithParser,
|
|
271
267
|
},
|
|
268
|
+
evaluate: (exp) =>
|
|
269
|
+
this.options.evaluator.evaluate(exp, { model: depModelWithParser }),
|
|
272
270
|
node,
|
|
273
|
-
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
resolveOptions = this.hooks.resolveOptions.call(resolveOptions, node);
|
|
275
|
+
} catch (err: unknown) {
|
|
276
|
+
throw new ResolverError(err, ResolverStage.ResolveOptions, node);
|
|
277
|
+
}
|
|
274
278
|
|
|
275
279
|
const previousResult = this.getPreviousResult(node);
|
|
276
280
|
const previousDeps = previousResult?.dependencies;
|
|
277
281
|
|
|
278
282
|
const isChanged = nodeChanges.has(node);
|
|
279
283
|
const dataChanged = caresAboutDataChanges(dataChanges, previousDeps);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
let shouldUseLastValue = !dataChanged && !isChanged;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
shouldUseLastValue = this.hooks.skipResolve.call(
|
|
288
|
+
shouldUseLastValue,
|
|
289
|
+
node,
|
|
290
|
+
resolveOptions,
|
|
291
|
+
);
|
|
292
|
+
} catch (err: unknown) {
|
|
293
|
+
throw new ResolverError(err, ResolverStage.SkipResolve, node);
|
|
294
|
+
}
|
|
285
295
|
|
|
286
296
|
if (previousResult && shouldUseLastValue) {
|
|
287
297
|
const update = {
|
|
@@ -325,7 +335,11 @@ export class Resolver {
|
|
|
325
335
|
resolvedASTLocal.values.forEach(handleChildNode);
|
|
326
336
|
}
|
|
327
337
|
|
|
328
|
-
|
|
338
|
+
try {
|
|
339
|
+
this.hooks.afterNodeUpdate.call(AST, ASTParent, resolvedUpdate);
|
|
340
|
+
} catch (err: unknown) {
|
|
341
|
+
throw new ResolverError(err, ResolverStage.AfterNodeUpdate, node);
|
|
342
|
+
}
|
|
329
343
|
};
|
|
330
344
|
|
|
331
345
|
// Point the root of the cached node to the new resolved node.
|
|
@@ -338,16 +352,20 @@ export class Resolver {
|
|
|
338
352
|
|
|
339
353
|
// Shallow clone the node so that changes to it during the resolve steps don't impact the original.
|
|
340
354
|
// We are trusting that this becomes a deep clone once the whole node tree has been traversed.
|
|
341
|
-
|
|
355
|
+
let resolvedAST: Node.Node = {
|
|
342
356
|
...this.cloneNode(node),
|
|
343
357
|
parent: partiallyResolvedParent,
|
|
344
358
|
};
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
359
|
+
try {
|
|
360
|
+
resolvedAST = this.hooks.beforeResolve.call(
|
|
361
|
+
resolvedAST,
|
|
362
|
+
resolveOptions,
|
|
363
|
+
) ?? {
|
|
364
|
+
type: NodeType.Empty,
|
|
365
|
+
};
|
|
366
|
+
} catch (err: unknown) {
|
|
367
|
+
throw new ResolverError(err, ResolverStage.BeforeResolve, node);
|
|
368
|
+
}
|
|
351
369
|
|
|
352
370
|
resolvedAST.parent = partiallyResolvedParent;
|
|
353
371
|
|
|
@@ -355,11 +373,16 @@ export class Resolver {
|
|
|
355
373
|
|
|
356
374
|
this.ASTMap.set(resolvedAST, node);
|
|
357
375
|
|
|
358
|
-
let resolved =
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
376
|
+
let resolved: any = undefined;
|
|
377
|
+
try {
|
|
378
|
+
resolved = this.hooks.resolve.call(
|
|
379
|
+
undefined,
|
|
380
|
+
resolvedAST,
|
|
381
|
+
resolveOptions,
|
|
382
|
+
);
|
|
383
|
+
} catch (err: unknown) {
|
|
384
|
+
throw new ResolverError(err, ResolverStage.Resolve, node);
|
|
385
|
+
}
|
|
363
386
|
|
|
364
387
|
let updated = !dequal(previousResult?.value, resolved);
|
|
365
388
|
|
|
@@ -449,11 +472,15 @@ export class Resolver {
|
|
|
449
472
|
resolved = previousResult?.value;
|
|
450
473
|
}
|
|
451
474
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
475
|
+
try {
|
|
476
|
+
resolved = this.hooks.afterResolve.call(resolved, resolvedAST, {
|
|
477
|
+
...resolveOptions,
|
|
478
|
+
getDependencies: (scope?: "core" | "children") =>
|
|
479
|
+
dependencyModel.getDependencies(scope),
|
|
480
|
+
});
|
|
481
|
+
} catch (err: unknown) {
|
|
482
|
+
throw new ResolverError(err, ResolverStage.AfterResolve, node);
|
|
483
|
+
}
|
|
457
484
|
|
|
458
485
|
const update: NodeUpdate = {
|
|
459
486
|
node: resolvedAST,
|
|
@@ -465,7 +492,11 @@ export class Resolver {
|
|
|
465
492
|
]),
|
|
466
493
|
};
|
|
467
494
|
|
|
468
|
-
|
|
495
|
+
try {
|
|
496
|
+
this.hooks.afterNodeUpdate.call(node, rawParent, update);
|
|
497
|
+
} catch (err: unknown) {
|
|
498
|
+
throw new ResolverError(err, ResolverStage.AfterNodeUpdate, node);
|
|
499
|
+
}
|
|
469
500
|
cacheUpdate.set(node, update);
|
|
470
501
|
|
|
471
502
|
return update;
|
|
@@ -200,3 +200,16 @@ export declare namespace Resolve {
|
|
|
200
200
|
afterResolve?: NodeResolveFunction;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
export enum ResolverStage {
|
|
205
|
+
ResolveOptions = "resolveOptions",
|
|
206
|
+
SkipResolve = "skipResolve",
|
|
207
|
+
BeforeResolve = "beforeResolve",
|
|
208
|
+
Resolve = "resolve",
|
|
209
|
+
AfterResolve = "afterResolve",
|
|
210
|
+
AfterNodeUpdate = "afterNodeUpdate",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type ResolverErrorMetadata = {
|
|
214
|
+
node: Node.Node;
|
|
215
|
+
};
|