@player-ui/player 0.4.0 → 0.4.1-next.1

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 (47) hide show
  1. package/dist/index.cjs.js +795 -390
  2. package/dist/index.d.ts +238 -81
  3. package/dist/index.esm.js +787 -388
  4. package/dist/player.dev.js +4768 -5282
  5. package/dist/player.prod.js +1 -1
  6. package/package.json +12 -3
  7. package/src/binding/binding.ts +8 -0
  8. package/src/binding/index.ts +14 -4
  9. package/src/binding/resolver.ts +1 -1
  10. package/src/binding-grammar/custom/index.ts +17 -9
  11. package/src/controllers/constants/index.ts +9 -5
  12. package/src/controllers/{data.ts → data/controller.ts} +60 -61
  13. package/src/controllers/data/index.ts +1 -0
  14. package/src/controllers/data/utils.ts +42 -0
  15. package/src/controllers/flow/controller.ts +16 -12
  16. package/src/controllers/flow/flow.ts +6 -1
  17. package/src/controllers/index.ts +1 -1
  18. package/src/controllers/validation/binding-tracker.ts +42 -19
  19. package/src/controllers/validation/controller.ts +359 -145
  20. package/src/controllers/view/asset-transform.ts +4 -1
  21. package/src/controllers/view/controller.ts +20 -3
  22. package/src/data/dependency-tracker.ts +14 -0
  23. package/src/data/local-model.ts +25 -1
  24. package/src/data/model.ts +55 -8
  25. package/src/data/noop-model.ts +2 -0
  26. package/src/expressions/evaluator-functions.ts +24 -2
  27. package/src/expressions/evaluator.ts +37 -33
  28. package/src/expressions/index.ts +1 -0
  29. package/src/expressions/parser.ts +53 -27
  30. package/src/expressions/types.ts +23 -5
  31. package/src/expressions/utils.ts +19 -0
  32. package/src/player.ts +47 -48
  33. package/src/plugins/default-exp-plugin.ts +57 -0
  34. package/src/plugins/flow-exp-plugin.ts +2 -2
  35. package/src/schema/schema.ts +28 -9
  36. package/src/string-resolver/index.ts +25 -9
  37. package/src/types.ts +6 -3
  38. package/src/validator/binding-map-splice.ts +59 -0
  39. package/src/validator/index.ts +1 -0
  40. package/src/validator/types.ts +11 -3
  41. package/src/validator/validation-middleware.ts +38 -4
  42. package/src/view/parser/index.ts +51 -3
  43. package/src/view/plugins/applicability.ts +1 -1
  44. package/src/view/plugins/string-resolver.ts +8 -4
  45. package/src/view/plugins/template-plugin.ts +1 -6
  46. package/src/view/resolver/index.ts +119 -54
  47. package/src/view/resolver/types.ts +48 -7
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@player-ui/player",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-next.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org"
7
7
  },
8
8
  "peerDependencies": {},
9
9
  "dependencies": {
10
- "@player-ui/partial-match-registry": "0.4.0",
11
- "@player-ui/types": "0.4.0",
10
+ "@player-ui/partial-match-registry": "0.4.1-next.1",
11
+ "@player-ui/types": "0.4.1-next.1",
12
12
  "dequal": "^2.0.2",
13
13
  "p-defer": "^3.0.0",
14
14
  "queue-microtask": "^1.2.3",
@@ -21,6 +21,7 @@
21
21
  "ebnf": "^1.9.0",
22
22
  "timm": "^1.6.2",
23
23
  "error-polyfill": "^0.1.3",
24
+ "ts-nested-error": "^1.2.1",
24
25
  "@babel/runtime": "7.15.4"
25
26
  },
26
27
  "main": "dist/index.cjs.js",
@@ -64,6 +65,14 @@
64
65
  {
65
66
  "name": "Kelly Harrop",
66
67
  "url": "https://github.com/kharrop"
68
+ },
69
+ {
70
+ "name": "Alejandro Fimbres",
71
+ "url": "https://github.com/lexfm"
72
+ },
73
+ {
74
+ "name": "Rafael Campos",
75
+ "url": "https://github.com/rafbcampos"
67
76
  }
68
77
  ],
69
78
  "bundle": "./dist/player.prod.js"
@@ -14,6 +14,14 @@ export interface BindingParserOptions {
14
14
  * Get the result of evaluating an expression
15
15
  */
16
16
  evaluate: (exp: string) => any;
17
+
18
+ /**
19
+ * Without readOnly, if a binding such as this is used: arr[key='does not exist'],
20
+ * then an object with that key will be created.
21
+ * This is done to make assignment such as arr[key='abc'].val = 'foo' work smoothly.
22
+ * Setting readOnly to true will prevent this behavior, avoiding unintended data changes.
23
+ */
24
+ readOnly?: boolean;
17
25
  }
18
26
 
19
27
  export type Getter = (path: BindingInstance) => any;
@@ -1,5 +1,5 @@
1
1
  import { SyncBailHook, SyncWaterfallHook } from 'tapable-ts';
2
- import NestedError from 'nested-error-stacks';
2
+ import { NestedError } from 'ts-nested-error';
3
3
  import type { ParserResult, AnyNode } from '../binding-grammar';
4
4
  import {
5
5
  // We can swap this with whichever parser we want to use
@@ -15,6 +15,8 @@ export * from './utils';
15
15
  export * from './binding';
16
16
 
17
17
  export const SIMPLE_BINDING_REGEX = /^[\w\-@]+(\.[\w\-@]+)*$/;
18
+ export const BINDING_BRACKETS_REGEX = /[\s()*=`{}'"[\]]/;
19
+ const LAZY_BINDING_REGEX = /^[^.]+(\..+)*$/;
18
20
 
19
21
  const DEFAULT_OPTIONS: BindingParserOptions = {
20
22
  get: () => {
@@ -28,6 +30,9 @@ const DEFAULT_OPTIONS: BindingParserOptions = {
28
30
  },
29
31
  };
30
32
 
33
+ type BeforeResolveNodeContext = Required<NormalizedResult> &
34
+ ResolveBindingASTOptions;
35
+
31
36
  /** A parser for creating bindings from a string */
32
37
  export class BindingParser {
33
38
  private cache: Record<string, BindingInstance>;
@@ -37,7 +42,7 @@ export class BindingParser {
37
42
  public hooks = {
38
43
  skipOptimization: new SyncBailHook<[string], boolean>(),
39
44
  beforeResolveNode: new SyncWaterfallHook<
40
- [AnyNode, Required<NormalizedResult> & ResolveBindingASTOptions]
45
+ [AnyNode, BeforeResolveNodeContext]
41
46
  >(),
42
47
  };
43
48
 
@@ -56,8 +61,13 @@ export class BindingParser {
56
61
  path: string,
57
62
  resolveOptions: ResolveBindingASTOptions
58
63
  ) {
64
+ /**
65
+ * Ensure no binding characters exist in path and the characters remaining
66
+ * look like a binding format.
67
+ */
59
68
  if (
60
- path.match(SIMPLE_BINDING_REGEX) &&
69
+ !BINDING_BRACKETS_REGEX.test(path) &&
70
+ LAZY_BINDING_REGEX.test(path) &&
61
71
  this.hooks.skipOptimization.call(path) !== true
62
72
  ) {
63
73
  return { path: path.split('.'), updates: undefined } as NormalizedResult;
@@ -172,7 +182,7 @@ export class BindingParser {
172
182
 
173
183
  const updateKeys = Object.keys(updates);
174
184
 
175
- if (updateKeys.length > 0) {
185
+ if (!options.readOnly && updateKeys.length > 0) {
176
186
  const updateTransaction = updateKeys.map<[BindingInstance, any]>(
177
187
  (updatedBinding) => [
178
188
  this.parse(updatedBinding),
@@ -1,4 +1,4 @@
1
- import NestedError from 'nested-error-stacks';
1
+ import { NestedError } from 'ts-nested-error';
2
2
  import type { SyncWaterfallHook } from 'tapable-ts';
3
3
  import type { PathNode, AnyNode } from '../binding-grammar';
4
4
  import { findInArray, maybeConvertToNum } from './utils';
@@ -26,7 +26,7 @@ const DOUBLE_QUOTE = '"';
26
26
  const BACK_TICK = '`';
27
27
  // const IDENTIFIER_REGEX = /[\w\-@]+/;
28
28
 
29
- /** A _faster_ way to match chars instead of a regex (/[\w\-@]+/) */
29
+ /** A _faster_ way to match chars instead of a regex. */
30
30
  const isIdentifierChar = (char?: string): boolean => {
31
31
  if (!char) {
32
32
  return false;
@@ -34,14 +34,22 @@ const isIdentifierChar = (char?: string): boolean => {
34
34
 
35
35
  const charCode = char.charCodeAt(0);
36
36
 
37
- return (
38
- (charCode >= 48 && charCode <= 57) || // 0 - 9
39
- (charCode >= 65 && charCode <= 90) || // A-Z
40
- (charCode >= 97 && charCode <= 122) || // a-z
41
- charCode === 95 || // _
42
- charCode === 45 || // -
43
- charCode === 64 // @
44
- );
37
+ const matches =
38
+ charCode === 32 || // ' '
39
+ charCode === 34 || // "
40
+ charCode === 39 || // '
41
+ charCode === 40 || // (
42
+ charCode === 41 || // )
43
+ charCode === 42 || // *
44
+ charCode === 46 || // .
45
+ charCode === 61 || // =
46
+ charCode === 91 || // [
47
+ charCode === 93 || // ]
48
+ charCode === 96 || // `
49
+ charCode === 123 || // {
50
+ charCode === 125; // }
51
+
52
+ return !matches;
45
53
  };
46
54
 
47
55
  /** Parse out a binding AST from a path */
@@ -10,7 +10,7 @@ export interface ConstantsProvider {
10
10
  addConstants(data: Record<string, any>, namespace: string): void;
11
11
 
12
12
  /**
13
- * Function to retreive constants from the providers store
13
+ * Function to retrieve constants from the providers store
14
14
  * - @param key Key used for the store access
15
15
  * - @param namespace namespace values were loaded under (defined in the plugin)
16
16
  * - @param fallback Optional - if key doesn't exist in namespace what to return (will return unknown if not provided)
@@ -77,9 +77,13 @@ export class ConstantsController implements ConstantsProvider {
77
77
  }
78
78
  }
79
79
 
80
- clearTemporaryValues(): void {
81
- this.tempStore.forEach((value: LocalModel) => {
82
- value.reset();
83
- });
80
+ clearTemporaryValues(namespace?: string): void {
81
+ if (namespace) {
82
+ this.tempStore.get(namespace)?.reset();
83
+ } else {
84
+ this.tempStore.forEach((value: LocalModel) => {
85
+ value.reset();
86
+ });
87
+ }
84
88
  }
85
89
  }
@@ -1,9 +1,8 @@
1
1
  import { SyncHook, SyncWaterfallHook, SyncBailHook } from 'tapable-ts';
2
- import { omit, removeAt } from 'timm';
3
2
  import { dequal } from 'dequal';
4
- import type { Logger } from '../logger';
5
- import type { BindingParser, BindingLike } from '../binding';
6
- import { BindingInstance } from '../binding';
3
+ import type { Logger } from '../../logger';
4
+ import type { BindingParser, BindingLike } from '../../binding';
5
+ import { BindingInstance } from '../../binding';
7
6
  import type {
8
7
  BatchSetTransaction,
9
8
  Updates,
@@ -11,9 +10,10 @@ import type {
11
10
  DataModelWithParser,
12
11
  DataPipeline,
13
12
  DataModelMiddleware,
14
- } from '../data';
15
- import { PipelinedDataModel, LocalModel } from '../data';
16
- import type { RawSetTransaction } from '../types';
13
+ } from '../../data';
14
+ import { PipelinedDataModel, LocalModel } from '../../data';
15
+ import type { RawSetTransaction } from '../../types';
16
+ import { ReadOnlyDataController } from './utils';
17
17
 
18
18
  /** The orchestrator for player data */
19
19
  export class DataController implements DataModelWithParser<DataModelOptions> {
@@ -25,11 +25,15 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
25
25
  resolveDefaultValue: new SyncBailHook<[BindingInstance], any>(),
26
26
 
27
27
  onDelete: new SyncHook<[any]>(),
28
+
28
29
  onSet: new SyncHook<[BatchSetTransaction]>(),
30
+
29
31
  onGet: new SyncHook<[any, any]>(),
32
+
30
33
  onUpdate: new SyncHook<[Updates, DataModelOptions | undefined]>(),
31
34
 
32
35
  format: new SyncWaterfallHook<[any, BindingInstance]>(),
36
+
33
37
  deformat: new SyncWaterfallHook<[any, BindingInstance]>(),
34
38
 
35
39
  serialize: new SyncWaterfallHook<[any]>(),
@@ -119,18 +123,24 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
119
123
  (updates, [binding, newVal]) => {
120
124
  const oldVal = this.get(binding, { includeInvalid: true });
121
125
 
122
- if (!dequal(oldVal, newVal)) {
123
- updates.push({
124
- binding,
125
- newValue: newVal,
126
- oldValue: oldVal,
127
- });
126
+ const update = {
127
+ binding,
128
+ newValue: newVal,
129
+ oldValue: oldVal,
130
+ };
131
+
132
+ if (dequal(oldVal, newVal)) {
133
+ this.logger?.debug(
134
+ `Skipping update for path: ${binding.asString()}. Value was unchanged: ${oldVal}`
135
+ );
136
+ } else {
137
+ updates.push(update);
138
+
139
+ this.logger?.debug(
140
+ `Setting path: ${binding.asString()} from: ${oldVal} to: ${newVal}`
141
+ );
128
142
  }
129
143
 
130
- this.logger?.debug(
131
- `Setting path: ${binding.asString()} from: ${oldVal} to: ${newVal}`
132
- );
133
-
134
144
  return updates;
135
145
  },
136
146
  []
@@ -157,8 +167,6 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
157
167
 
158
168
  this.hooks.onSet.call(normalizedTransaction);
159
169
 
160
- this.hooks.onSet.call(normalizedTransaction);
161
-
162
170
  if (setUpdates.length > 0) {
163
171
  this.hooks.onUpdate.call(setUpdates, options);
164
172
  }
@@ -166,15 +174,17 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
166
174
  return result;
167
175
  }
168
176
 
169
- private resolve(binding: BindingLike): BindingInstance {
177
+ private resolve(binding: BindingLike, readOnly: boolean): BindingInstance {
170
178
  return Array.isArray(binding) || typeof binding === 'string'
171
- ? this.pathResolver.parse(binding)
179
+ ? this.pathResolver.parse(binding, { readOnly })
172
180
  : binding;
173
181
  }
174
182
 
175
183
  public get(binding: BindingLike, options?: DataModelOptions) {
176
184
  const resolved =
177
- binding instanceof BindingInstance ? binding : this.resolve(binding);
185
+ binding instanceof BindingInstance
186
+ ? binding
187
+ : this.resolve(binding, true);
178
188
  let result = this.getModel().get(resolved, options);
179
189
 
180
190
  if (result === undefined && !options?.ignoreDefaultValue) {
@@ -187,6 +197,8 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
187
197
 
188
198
  if (options?.formatted) {
189
199
  result = this.hooks.format.call(result, resolved);
200
+ } else if (options?.formatted === false) {
201
+ result = this.hooks.deformat.call(result, resolved);
190
202
  }
191
203
 
192
204
  this.hooks.onGet.call(binding, result);
@@ -194,56 +206,43 @@ export class DataController implements DataModelWithParser<DataModelOptions> {
194
206
  return result;
195
207
  }
196
208
 
197
- public delete(binding: BindingLike) {
198
- if (binding === undefined || binding === null) {
199
- throw new Error(`Invalid arguments: delete expects a data path (string)`);
209
+ public delete(binding: BindingLike, options?: DataModelOptions) {
210
+ if (
211
+ typeof binding !== 'string' &&
212
+ !Array.isArray(binding) &&
213
+ !(binding instanceof BindingInstance)
214
+ ) {
215
+ throw new Error('Invalid arguments: delete expects a data path (string)');
200
216
  }
201
217
 
202
- const resolved = this.resolve(binding);
203
- this.hooks.onDelete.call(resolved);
204
- this.deleteData(resolved);
205
- }
206
-
207
- public getTrash(): Set<BindingInstance> {
208
- return this.trash;
209
- }
210
-
211
- private addToTrash(binding: BindingInstance) {
212
- this.trash.add(binding);
213
- }
218
+ const resolved =
219
+ binding instanceof BindingInstance
220
+ ? binding
221
+ : this.resolve(binding, false);
214
222
 
215
- private deleteData(binding: BindingInstance) {
216
- const parentBinding = binding.parent();
217
- const parentPath = parentBinding.asString();
218
- const property = binding.key();
223
+ const parentBinding = resolved.parent();
224
+ const property = resolved.key();
225
+ const parentValue = this.get(parentBinding);
219
226
 
220
- const existedBeforeDelete = Object.prototype.hasOwnProperty.call(
221
- this.get(parentBinding),
222
- property
223
- );
227
+ const existedBeforeDelete =
228
+ typeof parentValue === 'object' &&
229
+ parentValue !== null &&
230
+ Object.prototype.hasOwnProperty.call(parentValue, property);
224
231
 
225
- if (property !== undefined) {
226
- const parent = parentBinding ? this.get(parentBinding) : undefined;
232
+ this.getModel().delete(resolved, options);
227
233
 
228
- // If we're deleting an item in an array, we just splice it out
229
- // Don't add it to the trash
230
- if (parentPath && Array.isArray(parent)) {
231
- if (parent.length > property) {
232
- this.set([[parentBinding, removeAt(parent, property as number)]]);
233
- }
234
- } else if (parentPath && parent[property]) {
235
- this.set([[parentBinding, omit(parent, property as string)]]);
236
- } else if (!parentPath) {
237
- this.getModel().reset(omit(this.get(''), property as string));
238
- }
234
+ if (existedBeforeDelete && !this.get(resolved)) {
235
+ this.trash.add(resolved);
239
236
  }
240
237
 
241
- if (existedBeforeDelete && !this.get(binding)) {
242
- this.addToTrash(binding);
243
- }
238
+ this.hooks.onDelete.call(resolved);
244
239
  }
245
240
 
246
241
  public serialize(): object {
247
242
  return this.hooks.serialize.call(this.get(''));
248
243
  }
244
+
245
+ public makeReadOnly(): ReadOnlyDataController {
246
+ return new ReadOnlyDataController(this, this.logger);
247
+ }
249
248
  }
@@ -0,0 +1 @@
1
+ export * from './controller';
@@ -0,0 +1,42 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import type { DataController } from '.';
3
+ import type { Logger } from '../../logger';
4
+ import type { BindingLike } from '../../binding';
5
+ import type {
6
+ DataModelWithParser,
7
+ DataModelOptions,
8
+ Updates,
9
+ } from '../../data';
10
+
11
+ /** Wrapper for the Data Controller Class that prevents writes */
12
+ export class ReadOnlyDataController
13
+ implements DataModelWithParser<DataModelOptions>
14
+ {
15
+ private controller: DataController;
16
+ private logger?: Logger;
17
+
18
+ constructor(controller: DataController, logger?: Logger) {
19
+ this.controller = controller;
20
+ this.logger = logger;
21
+ }
22
+
23
+ get(binding: BindingLike, options?: DataModelOptions | undefined) {
24
+ return this.controller.get(binding, options);
25
+ }
26
+
27
+ set(
28
+ transaction: [BindingLike, any][],
29
+ options?: DataModelOptions | undefined
30
+ ): Updates {
31
+ this.logger?.error(
32
+ 'Error: Tried to set in a read only instance of the DataController'
33
+ );
34
+ return [];
35
+ }
36
+
37
+ delete(binding: BindingLike, options?: DataModelOptions | undefined): void {
38
+ this.logger?.error(
39
+ 'Error: Tried to delete in a read only instance of the DataController'
40
+ );
41
+ }
42
+ }
@@ -29,6 +29,7 @@ export class FlowController {
29
29
  this.start = this.start.bind(this);
30
30
  this.run = this.run.bind(this);
31
31
  this.transition = this.transition.bind(this);
32
+ this.addNewFlow = this.addNewFlow.bind(this);
32
33
  }
33
34
 
34
35
  /** Navigate to another state in the state-machine */
@@ -40,20 +41,9 @@ export class FlowController {
40
41
  this.current.transition(stateTransition, options);
41
42
  }
42
43
 
43
- private async addNewFlow(flow: FlowInstance) {
44
+ private addNewFlow(flow: FlowInstance) {
44
45
  this.navStack.push(flow);
45
46
  this.current = flow;
46
- flow.hooks.transition.tap(
47
- 'flow-controller',
48
- async (_oldState, newState: NamedState) => {
49
- if (newState.value.state_type === 'FLOW') {
50
- this.log?.debug(`Got FLOW state. Loading flow ${newState.value.ref}`);
51
- const endState = await this.run(newState.value.ref);
52
- this.log?.debug(`Flow ended. Using outcome: ${endState.outcome}`);
53
- flow.transition(endState.outcome);
54
- }
55
- }
56
- );
57
47
  this.hooks.flow.call(flow);
58
48
  }
59
49
 
@@ -74,6 +64,20 @@ export class FlowController {
74
64
 
75
65
  const flow = new FlowInstance(startState, startFlow, { logger: this.log });
76
66
  this.addNewFlow(flow);
67
+
68
+ flow.hooks.afterTransition.tap('flow-controller', (flowInstance) => {
69
+ if (flowInstance.currentState?.value.state_type === 'FLOW') {
70
+ const subflowId = flowInstance.currentState?.value.ref;
71
+ this.log?.debug(`Loading subflow ${subflowId}`);
72
+ this.run(subflowId).then((subFlowEndState) => {
73
+ this.log?.debug(
74
+ `Subflow ended. Using outcome: ${subFlowEndState.outcome}`
75
+ );
76
+ flowInstance.transition(subFlowEndState?.outcome);
77
+ });
78
+ }
79
+ });
80
+
77
81
  const end = await flow.start();
78
82
  this.navStack.pop();
79
83
 
@@ -58,6 +58,9 @@ export class FlowInstance {
58
58
 
59
59
  /** A callback when a transition from 1 state to another was made */
60
60
  transition: new SyncHook<[NamedState | undefined, NamedState]>(),
61
+
62
+ /** A callback to run actions after a transition occurs */
63
+ afterTransition: new SyncHook<[FlowInstance]>(),
61
64
  };
62
65
 
63
66
  constructor(
@@ -131,7 +134,7 @@ export class FlowInstance {
131
134
 
132
135
  if (skipTransition) {
133
136
  this.log?.debug(
134
- `Skipping transition from ${this.currentState} b/c hook told us to`
137
+ `Skipping transition from ${this.currentState.name} b/c hook told us to`
135
138
  );
136
139
  return;
137
140
  }
@@ -201,5 +204,7 @@ export class FlowInstance {
201
204
  this.hooks.transition.call(prevState, {
202
205
  ...newCurrentState,
203
206
  });
207
+
208
+ this.hooks.afterTransition.call(this);
204
209
  }
205
210
  }
@@ -1,5 +1,5 @@
1
1
  export * from './flow';
2
2
  export * from './validation';
3
3
  export * from './view';
4
- export * from './data';
4
+ export * from './data/controller';
5
5
  export * from './constants';
@@ -13,6 +13,9 @@ const CONTEXT = 'validation-binding-tracker';
13
13
  export interface BindingTracker {
14
14
  /** Get the bindings currently being tracked for validation */
15
15
  getBindings(): Set<BindingInstance>;
16
+
17
+ /** Add a binding to the tracked set */
18
+ trackBinding(binding: BindingInstance): void;
16
19
  }
17
20
  interface Options {
18
21
  /** Parse a binding from a view */
@@ -42,6 +45,16 @@ export class ValidationBindingTrackerViewPlugin
42
45
  return this.trackedBindings;
43
46
  }
44
47
 
48
+ /** Add a binding to the tracked set */
49
+ trackBinding(binding: BindingInstance) {
50
+ if (this.trackedBindings.has(binding)) {
51
+ return;
52
+ }
53
+
54
+ this.trackedBindings.add(binding);
55
+ this.options.callbacks?.onAdd?.(binding);
56
+ }
57
+
45
58
  /** Attach hooks to the given resolver */
46
59
  applyResolver(resolver: Resolver) {
47
60
  this.trackedBindings.clear();
@@ -52,9 +65,6 @@ export class ValidationBindingTrackerViewPlugin
52
65
  /** Each Node is a registered section or page that maps to a set of nodes in its section */
53
66
  const sections = new Map<Node.Node, Set<Node.Node>>();
54
67
 
55
- /** Keep track of all seen bindings so we can notify people when we hit one for the first time */
56
- const seenBindings = new Set<BindingInstance>();
57
-
58
68
  let lastViewUpdateChangeSet: Set<BindingInstance> | undefined;
59
69
 
60
70
  const nodeTree = new Map<Node.Node, Set<Node.Node>>();
@@ -129,10 +139,8 @@ export class ValidationBindingTrackerViewPlugin
129
139
  }
130
140
  }
131
141
 
132
- if (!seenBindings.has(parsed)) {
133
- seenBindings.add(parsed);
134
- this.options.callbacks?.onAdd?.(parsed);
135
- }
142
+ this.trackedBindings.add(parsed);
143
+ this.options.callbacks?.onAdd?.(parsed);
136
144
  };
137
145
 
138
146
  return {
@@ -144,23 +152,36 @@ export class ValidationBindingTrackerViewPlugin
144
152
  track(binding);
145
153
  }
146
154
 
147
- const eow = options.validation?._getValidationForBinding(binding);
155
+ const eows = options.validation
156
+ ?._getValidationForBinding(binding)
157
+ ?.getAll(getOptions);
158
+
159
+ const firstFieldEOW = eows?.find(
160
+ (eow) =>
161
+ eow.displayTarget === 'field' || eow.displayTarget === undefined
162
+ );
148
163
 
149
- if (
150
- eow?.displayTarget === undefined ||
151
- eow?.displayTarget === 'field'
152
- ) {
153
- return eow;
164
+ return firstFieldEOW;
165
+ },
166
+ getValidationsForBinding(binding, getOptions) {
167
+ if (getOptions?.track) {
168
+ track(binding);
154
169
  }
155
170
 
156
- return undefined;
171
+ return (
172
+ options.validation
173
+ ?._getValidationForBinding(binding)
174
+ ?.getAll(getOptions) ?? []
175
+ );
157
176
  },
158
- getChildren: (type: Validation.DisplayTarget) => {
177
+ getChildren: (type?: Validation.DisplayTarget) => {
159
178
  const validations = new Array<ValidationResponse>();
160
179
  lastComputedBindingTree.get(node)?.forEach((binding) => {
161
- const eow = options.validation?._getValidationForBinding(binding);
180
+ const eow = options.validation
181
+ ?._getValidationForBinding(binding)
182
+ ?.get();
162
183
 
163
- if (eow && type === eow.displayTarget) {
184
+ if (eow && (type === undefined || type === eow.displayTarget)) {
164
185
  validations.push(eow);
165
186
  }
166
187
  });
@@ -170,7 +191,9 @@ export class ValidationBindingTrackerViewPlugin
170
191
  getValidationsForSection: () => {
171
192
  const validations = new Array<ValidationResponse>();
172
193
  lastSectionBindingTree.get(node)?.forEach((binding) => {
173
- const eow = options.validation?._getValidationForBinding(binding);
194
+ const eow = options.validation
195
+ ?._getValidationForBinding(binding)
196
+ ?.get();
174
197
 
175
198
  if (eow && eow.displayTarget === 'section') {
176
199
  validations.push(eow);
@@ -213,7 +236,7 @@ export class ValidationBindingTrackerViewPlugin
213
236
  }
214
237
 
215
238
  if (node === resolver.root) {
216
- this.trackedBindings = currentBindingTree.get(node) ?? new Set();
239
+ this.trackedBindings = new Set(currentBindingTree.get(node));
217
240
  lastComputedBindingTree = currentBindingTree;
218
241
 
219
242
  lastSectionBindingTree.clear();