@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.
Files changed (56) hide show
  1. package/dist/Player.native.js +3259 -2768
  2. package/dist/Player.native.js.map +1 -1
  3. package/dist/cjs/index.cjs +2553 -2114
  4. package/dist/cjs/index.cjs.map +1 -1
  5. package/dist/index.legacy-esm.js +2535 -2103
  6. package/dist/index.mjs +2535 -2103
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -4
  9. package/src/__tests__/data.test.ts +0 -13
  10. package/src/__tests__/view.test.ts +34 -1
  11. package/src/controllers/data/controller.ts +1 -1
  12. package/src/controllers/data/utils.ts +5 -26
  13. package/src/controllers/error/__tests__/controller.test.ts +359 -0
  14. package/src/controllers/error/__tests__/middleware.test.ts +237 -0
  15. package/src/controllers/error/__tests__/navigation.test.ts +190 -0
  16. package/src/controllers/error/controller.ts +257 -0
  17. package/src/controllers/error/index.ts +3 -0
  18. package/src/controllers/error/middleware.ts +106 -0
  19. package/src/controllers/error/types.ts +42 -0
  20. package/src/controllers/error/utils/__tests__/isErrorWithMetadata.test.ts +114 -0
  21. package/src/controllers/error/utils/__tests__/makeJsonStringifyReplacer.test.ts +24 -0
  22. package/src/controllers/error/utils/index.ts +2 -0
  23. package/src/controllers/error/utils/isErrorWithMetadata.ts +28 -0
  24. package/src/controllers/error/utils/makeJsonStringifyReplacer.ts +17 -0
  25. package/src/controllers/flow/__tests__/flow.test.ts +268 -0
  26. package/src/controllers/flow/flow.ts +96 -4
  27. package/src/controllers/index.ts +1 -0
  28. package/src/controllers/view/controller.ts +22 -3
  29. package/src/data/model.ts +6 -0
  30. package/src/expressions/types.ts +8 -4
  31. package/src/player.ts +20 -1
  32. package/src/types.ts +6 -0
  33. package/src/validator/types.ts +2 -1
  34. package/src/view/parser/types.ts +6 -3
  35. package/src/view/plugins/__tests__/template.test.ts +7 -2
  36. package/src/view/resolver/ResolverError.ts +25 -0
  37. package/src/view/resolver/__tests__/index.test.ts +53 -1
  38. package/src/view/resolver/index.ts +68 -37
  39. package/src/view/resolver/types.ts +13 -0
  40. package/src/view/resolver/utils.ts +1 -1
  41. package/types/controllers/data/utils.d.ts +3 -7
  42. package/types/controllers/error/controller.d.ts +82 -0
  43. package/types/controllers/error/index.d.ts +4 -0
  44. package/types/controllers/error/middleware.d.ts +23 -0
  45. package/types/controllers/error/types.d.ts +35 -0
  46. package/types/controllers/error/utils/index.d.ts +3 -0
  47. package/types/controllers/error/utils/isErrorWithMetadata.d.ts +3 -0
  48. package/types/controllers/error/utils/makeJsonStringifyReplacer.d.ts +5 -0
  49. package/types/controllers/flow/flow.d.ts +17 -0
  50. package/types/controllers/index.d.ts +1 -0
  51. package/types/controllers/view/controller.d.ts +4 -0
  52. package/types/data/model.d.ts +5 -0
  53. package/types/types.d.ts +5 -1
  54. package/types/view/resolver/ResolverError.d.ts +13 -0
  55. package/types/view/resolver/index.d.ts +2 -1
  56. 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 and END state`,
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
- this.currentState.value,
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(nextState);
325
+ nextState = this.hooks.resolveTransitionNode.call(
326
+ nextState as NavigationFlowState,
327
+ );
236
328
 
237
329
  const newCurrentState = {
238
330
  name: stateName,
@@ -3,3 +3,4 @@ export * from "./validation";
3
3
  export * from "./view";
4
4
  export * from "./data/controller";
5
5
  export * from "./constants";
6
+ export * from "./error";
@@ -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.currentView.update();
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.currentView?.update(changedBindings, changedNodes);
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
- view.update();
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 */
@@ -129,13 +129,15 @@ export interface LiteralNode extends BaseNode<"Literal"> {
129
129
  }
130
130
 
131
131
  export interface BinaryNode
132
- extends BaseNode<"BinaryExpression">, DirectionalNode {
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">, DirectionalNode {
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 extends BaseNode<"ConditionalExpression"> {
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">, DirectionalNode {
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: validationController.getDataMiddleware(),
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 */
@@ -23,7 +23,8 @@ interface BaseValidationResponse<T = Validation.Severity> {
23
23
  blocking?: boolean | "once";
24
24
  }
25
25
 
26
- export interface WarningValidationResponse extends BaseValidationResponse<"warning"> {
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
  }
@@ -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>, PluginOptions {
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>, PluginOptions {
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>, PluginOptions {
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 { TemplatePlugin, MultiNodePlugin, AssetPlugin } from "../";
11
- import { StringResolverPlugin, toNodeResolveOptions } from "../..";
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 { Resolve } from "./types";
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
- const resolveOptions = this.hooks.resolveOptions.call(
262
- {
263
- ...options,
264
- data: {
265
- ...options.data,
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
- const shouldUseLastValue = this.hooks.skipResolve.call(
281
- !dataChanged && !isChanged,
282
- node,
283
- resolveOptions,
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
- this.hooks.afterNodeUpdate.call(AST, ASTParent, resolvedUpdate);
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
- const clonedNode: Node.Node = {
355
+ let resolvedAST: Node.Node = {
342
356
  ...this.cloneNode(node),
343
357
  parent: partiallyResolvedParent,
344
358
  };
345
- const resolvedAST = this.hooks.beforeResolve.call(
346
- clonedNode,
347
- resolveOptions,
348
- ) ?? {
349
- type: NodeType.Empty,
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 = this.hooks.resolve.call(
359
- undefined,
360
- resolvedAST,
361
- resolveOptions,
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
- resolved = this.hooks.afterResolve.call(resolved, resolvedAST, {
453
- ...resolveOptions,
454
- getDependencies: (scope?: "core" | "children") =>
455
- dependencyModel.getDependencies(scope),
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
- this.hooks.afterNodeUpdate.call(node, rawParent, update);
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
+ };