@player-ui/player 0.13.0-next.1 → 0.13.0-next.2

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/package.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "types"
7
7
  ],
8
8
  "name": "@player-ui/player",
9
- "version": "0.13.0-next.1",
9
+ "version": "0.13.0-next.2",
10
10
  "main": "dist/cjs/index.cjs",
11
11
  "dependencies": {
12
- "@player-ui/partial-match-registry": "0.13.0-next.1",
13
- "@player-ui/make-flow": "0.13.0-next.1",
14
- "@player-ui/types": "0.13.0-next.1",
12
+ "@player-ui/partial-match-registry": "0.13.0-next.2",
13
+ "@player-ui/make-flow": "0.13.0-next.2",
14
+ "@player-ui/types": "0.13.0-next.2",
15
15
  "@types/dlv": "^1.1.4",
16
16
  "dequal": "^2.0.2",
17
17
  "dlv": "^1.1.3",
@@ -4,6 +4,7 @@ import type { DataController } from "..";
4
4
  import { Player } from "..";
5
5
  import type { InProgressState } from "../types";
6
6
  import { waitFor } from "@testing-library/react";
7
+ import { describe } from "node:test";
7
8
 
8
9
  test("transitions on action nodes", async () => {
9
10
  const player = new Player();
@@ -314,9 +315,10 @@ test("works with iffe flows", async () => {
314
315
 
315
316
  test("awaited async transitions", async () => {
316
317
  const player = new Player();
317
-
318
+ let counter = 0;
318
319
  player.hooks.expressionEvaluator.tap("test", (expEval) => {
319
320
  expEval.addExpressionFunction("testAsync", async (ctx, name) => {
321
+ counter += 1;
320
322
  return new Promise((resolve) => {
321
323
  setTimeout(() => {
322
324
  resolve(name);
@@ -377,6 +379,8 @@ test("awaited async transitions", async () => {
377
379
  },
378
380
  transitions: {},
379
381
  });
382
+
383
+ expect(counter).toEqual(1);
380
384
  });
381
385
 
382
386
  test("unawaited async transitions", async () => {
@@ -445,3 +449,163 @@ test("unawaited async transitions", async () => {
445
449
  expect(mockFn2).not.toHaveBeenCalled();
446
450
  });
447
451
  });
452
+
453
+ describe("edge cases", () => {
454
+ test("async action nodes firing async expressions more than once after a regular action node", async () => {
455
+ const player = new Player();
456
+ let asyncCounter = 0;
457
+ let syncCounter = 0;
458
+ player.hooks.expressionEvaluator.tap("test", (expEval) => {
459
+ expEval.addExpressionFunction("testAsync", async (ctx, name) => {
460
+ asyncCounter += 1;
461
+ return new Promise((resolve) => {
462
+ setTimeout(() => {
463
+ resolve(name);
464
+ }, 10);
465
+ });
466
+ });
467
+ expEval.addExpressionFunction("testSync", (ctx) => {
468
+ syncCounter += 1;
469
+ return "foo";
470
+ });
471
+ });
472
+
473
+ player.start({
474
+ id: "test-flow",
475
+ data: {
476
+ my: {
477
+ puppy: "Ginger",
478
+ },
479
+ },
480
+ navigation: {
481
+ BEGIN: "FLOW_1",
482
+ FLOW_1: {
483
+ startState: "ACTION_1",
484
+ ACTION_1: {
485
+ state_type: "ACTION",
486
+ exp: "{{something}} = testSync()",
487
+ transitions: {
488
+ "*": "ACTION_2",
489
+ },
490
+ },
491
+ ACTION_2: {
492
+ state_type: "ACTION",
493
+ exp: "{{something}} = testSync()",
494
+ transitions: {
495
+ "*": "ACTION_3",
496
+ },
497
+ },
498
+ ACTION_3: {
499
+ state_type: "ASYNC_ACTION",
500
+ exp: "{{my.puppy}} = await(testAsync('Daisy'))",
501
+ transitions: {
502
+ Daisy: "EXTERNAL_1",
503
+ },
504
+ await: true,
505
+ },
506
+ EXTERNAL_1: {
507
+ state_type: "EXTERNAL",
508
+ ref: "view_1",
509
+ param: {
510
+ best: "{{my.puppy}}",
511
+ },
512
+ transitions: {},
513
+ },
514
+ },
515
+ },
516
+ });
517
+
518
+ await vitest.waitFor(() =>
519
+ expect(player.getState().status).toBe("in-progress"),
520
+ );
521
+
522
+ let currentState: NamedState | undefined;
523
+
524
+ await waitFor(() => {
525
+ const state = player.getState();
526
+ currentState = (state as InProgressState).controllers.flow.current
527
+ ?.currentState;
528
+ expect(currentState?.name).toBe("EXTERNAL_1");
529
+ });
530
+
531
+ expect(currentState?.value.ref).toStrictEqual("view_1");
532
+
533
+ expect(asyncCounter).toEqual(1);
534
+ expect(syncCounter).toEqual(2);
535
+ });
536
+
537
+ test("async action nodes only transition when the async action is resolved", async () => {
538
+ const player = new Player();
539
+ let expressionResolve: (val: any) => void;
540
+
541
+ player.hooks.expressionEvaluator.tap("test", (expEval) => {
542
+ expEval.addExpressionFunction("testAsync", async (ctx, name) => {
543
+ return new Promise((resolve) => {
544
+ expressionResolve = resolve;
545
+ });
546
+ });
547
+ });
548
+
549
+ player.start({
550
+ id: "test-flow",
551
+ views: [
552
+ {
553
+ id: "test",
554
+ type: "test",
555
+ },
556
+ ],
557
+ data: {
558
+ my: {
559
+ puppy: "Ginger",
560
+ },
561
+ },
562
+ navigation: {
563
+ BEGIN: "FLOW_1",
564
+ FLOW_1: {
565
+ startState: "ACTION_1",
566
+ ACTION_1: {
567
+ state_type: "ASYNC_ACTION",
568
+ exp: "{{my.puppy}} = await(testAsync('Daisy'))",
569
+ transitions: {
570
+ "*": "VIEW_1",
571
+ },
572
+ await: true,
573
+ },
574
+ VIEW_1: {
575
+ state_type: "VIEW",
576
+ ref: "test",
577
+ transitions: {
578
+ "*": "END",
579
+ },
580
+ },
581
+ END: {
582
+ state_type: "END",
583
+ outcome: "done",
584
+ },
585
+ },
586
+ },
587
+ });
588
+
589
+ await vitest.waitFor(() =>
590
+ expect(player.getState().status).toBe("in-progress"),
591
+ );
592
+
593
+ let currentState: NamedState | undefined;
594
+
595
+ await waitFor(() => {
596
+ const state = player.getState();
597
+ expect(state.status).toBe("in-progress");
598
+ currentState = (state as InProgressState).controllers.flow.current
599
+ ?.currentState;
600
+ expect(currentState?.name).toBe("ACTION_1");
601
+ expressionResolve("foo");
602
+ });
603
+
604
+ await waitFor(() => {
605
+ const state = player.getState();
606
+ currentState = (state as InProgressState).controllers.flow.current
607
+ ?.currentState;
608
+ expect(currentState?.name).toBe("VIEW_1");
609
+ });
610
+ });
611
+ });
@@ -543,7 +543,7 @@ describe("failure cases", () => {
543
543
  );
544
544
  });
545
545
 
546
- it("fails gracefully when states after an ACTION state have failures", async () => {
546
+ it("fails gracefully when states after an ACTION state have failures (sync)", async () => {
547
547
  const player = new Player();
548
548
 
549
549
  const payload = {
@@ -581,6 +581,56 @@ describe("failure cases", () => {
581
581
  );
582
582
  });
583
583
 
584
+ it("fails gracefully when states after an ACTION state have failures (async)", async () => {
585
+ const player = new Player();
586
+
587
+ player.hooks.expressionEvaluator.tap("test", (expEval) => {
588
+ expEval.addExpressionFunction("testAsync", async (ctx, name) => {
589
+ return new Promise((resolve) => {
590
+ setTimeout(() => {
591
+ resolve(name);
592
+ }, 1);
593
+ });
594
+ });
595
+ });
596
+
597
+ const payload = {
598
+ id: "test",
599
+ views: [
600
+ {
601
+ id: "view",
602
+ type: "text",
603
+ value: "Some text",
604
+ },
605
+ ],
606
+ data: {},
607
+ navigation: {
608
+ BEGIN: "Flow",
609
+ Flow: {
610
+ startState: "ActionState",
611
+ ActionState: {
612
+ state_type: "ASYNC_ACTION",
613
+ exp: "await(testAsync('test'))",
614
+ transitions: {
615
+ test: "ViewState",
616
+ },
617
+ await: true,
618
+ },
619
+ ViewState: {
620
+ state_type: "VIEW",
621
+ ref: "non-existing-view-async",
622
+ },
623
+ },
624
+ },
625
+ };
626
+
627
+ const response = player.start(makeFlow(payload));
628
+
629
+ await expect(response).rejects.toThrowError(
630
+ "No view with id non-existing-view-async",
631
+ );
632
+ });
633
+
584
634
  it("can be failed from other places", async () => {
585
635
  const player = new Player();
586
636
 
@@ -4,9 +4,13 @@ import type { Logger } from "../../logger";
4
4
  import type { TransitionOptions } from "./flow";
5
5
  import { FlowInstance } from "./flow";
6
6
 
7
+ export interface FlowControllerHooks {
8
+ flow: SyncHook<[FlowInstance], Record<string, any>>;
9
+ }
10
+
7
11
  /** A manager for the navigation section of a Content blob */
8
12
  export class FlowController {
9
- public readonly hooks = {
13
+ public readonly hooks: FlowControllerHooks = {
10
14
  flow: new SyncHook<[FlowInstance]>(),
11
15
  };
12
16
 
@@ -33,7 +37,10 @@ export class FlowController {
33
37
  }
34
38
 
35
39
  /** Navigate to another state in the state-machine */
36
- public transition(stateTransition: string, options?: TransitionOptions) {
40
+ public transition(
41
+ stateTransition: string,
42
+ options?: TransitionOptions,
43
+ ): void {
37
44
  if (this.current === undefined) {
38
45
  throw new Error("Not currently in a flow. Cannot transition.");
39
46
  }
@@ -5,6 +5,11 @@ import type {
5
5
  NavigationFlow,
6
6
  NavigationFlowState,
7
7
  NavigationFlowEndState,
8
+ NavigationFlowActionState,
9
+ NavigationFlowAsyncActionState,
10
+ NavigationFlowExternalState,
11
+ NavigationFlowFlowState,
12
+ NavigationFlowViewState,
8
13
  } from "@player-ui/types";
9
14
  import type { Logger } from "../../logger";
10
15
 
@@ -25,6 +30,50 @@ export type TransitionFunction = (
25
30
  options?: TransitionOptions,
26
31
  ) => void;
27
32
 
33
+ export interface FlowInstanceHooks {
34
+ beforeStart: SyncBailHook<
35
+ [NavigationFlow],
36
+ NavigationFlow,
37
+ Record<string, any>
38
+ >;
39
+ /** A callback when the onStart node was present */
40
+ onStart: SyncHook<[any], Record<string, any>>;
41
+ /** A callback when the onEnd node was present */
42
+ onEnd: SyncHook<[any], Record<string, any>>;
43
+ /** A hook to intercept and block a transition */
44
+ skipTransition: SyncBailHook<
45
+ [NamedState | undefined],
46
+ boolean | undefined,
47
+ Record<string, any>
48
+ >;
49
+ /** A chance to manipulate the flow-node used to calculate the given transition used */
50
+ beforeTransition: SyncWaterfallHook<
51
+ [
52
+ (
53
+ | NavigationFlowViewState
54
+ | NavigationFlowFlowState
55
+ | NavigationFlowActionState
56
+ | NavigationFlowAsyncActionState
57
+ | NavigationFlowExternalState
58
+ ),
59
+ string,
60
+ ],
61
+ Record<string, any>
62
+ >;
63
+ /** A chance to manipulate the flow-node calculated after a transition */
64
+ resolveTransitionNode: SyncWaterfallHook<
65
+ [NavigationFlowState],
66
+ Record<string, any>
67
+ >;
68
+ /** A callback when a transition from 1 state to another was made */
69
+ transition: SyncHook<
70
+ [NamedState | undefined, NamedState],
71
+ Record<string, any>
72
+ >;
73
+ /** A callback to run actions after a transition occurs */
74
+ afterTransition: SyncHook<[FlowInstance], Record<string, any>>;
75
+ }
76
+
28
77
  /** The Content navigation state machine */
29
78
  export class FlowInstance {
30
79
  private flow: NavigationFlow;
@@ -34,33 +83,19 @@ export class FlowInstance {
34
83
  private flowPromise?: DeferredPromise<NavigationFlowEndState>;
35
84
  public readonly id: string;
36
85
  public currentState?: NamedState;
37
- public readonly hooks = {
86
+ public readonly hooks: FlowInstanceHooks = {
38
87
  beforeStart: new SyncBailHook<[NavigationFlow], NavigationFlow>(),
39
-
40
- /** A callback when the onStart node was present */
41
88
  onStart: new SyncHook<[any]>(),
42
-
43
- /** A callback when the onEnd node was present */
44
89
  onEnd: new SyncHook<[any]>(),
45
-
46
- /** A hook to intercept and block a transition */
47
90
  skipTransition: new SyncBailHook<
48
91
  [NamedState | undefined],
49
92
  boolean | undefined
50
93
  >(),
51
-
52
- /** A chance to manipulate the flow-node used to calculate the given transition used */
53
94
  beforeTransition: new SyncWaterfallHook<
54
95
  [Exclude<NavigationFlowState, NavigationFlowEndState>, string]
55
96
  >(),
56
-
57
- /** A chance to manipulate the flow-node calculated after a transition */
58
97
  resolveTransitionNode: new SyncWaterfallHook<[NavigationFlowState]>(),
59
-
60
- /** A callback when a transition from 1 state to another was made */
61
98
  transition: new SyncHook<[NamedState | undefined, NamedState]>(),
62
-
63
- /** A callback to run actions after a transition occurs */
64
99
  afterTransition: new SyncHook<[FlowInstance]>(),
65
100
  };
66
101
 
@@ -115,7 +150,10 @@ export class FlowInstance {
115
150
  return this.flowPromise.promise;
116
151
  }
117
152
 
118
- public transition(transitionValue: string, options?: TransitionOptions) {
153
+ public transition(
154
+ transitionValue: string,
155
+ options?: TransitionOptions,
156
+ ): void {
119
157
  if (this.isTransitioning) {
120
158
  throw new Error(
121
159
  `Transitioning while ongoing transition from ${this.currentState?.name} is in progress is not supported`,
@@ -71,7 +71,6 @@ export class ViewController {
71
71
  this.viewOptions = options;
72
72
  this.viewMap = initialViews.reduce<Record<string, View>>(
73
73
  (viewMap, view) => {
74
- // eslint-disable-next-line no-param-reassign
75
74
  viewMap[view.id] = view;
76
75
  return viewMap;
77
76
  },
@@ -171,7 +170,7 @@ export class ViewController {
171
170
  }
172
171
  }
173
172
 
174
- public onView(state: NavigationFlowViewState) {
173
+ public onView(state: NavigationFlowViewState): void {
175
174
  const viewId = state.ref;
176
175
 
177
176
  const source = this.hooks.resolveView.call(
@@ -470,6 +470,16 @@ describe("async evaluator", () => {
470
470
  expect(await result).toBe("truthy");
471
471
  });
472
472
 
473
+ test("Async functions are only called once", async () => {
474
+ const mockHandler = vitest.fn().mockReturnValue(Promise.resolve(true));
475
+ evaluator.addExpressionFunction("asyncTest", mockHandler);
476
+
477
+ const result = evaluator.evaluateAsync("await(asyncTest())");
478
+ expect(result).toBeInstanceOf(Promise);
479
+ expect(await result).toBe(true);
480
+ expect(mockHandler).toBeCalledTimes(1);
481
+ });
482
+
473
483
  test("logical operators with async values", async () => {
474
484
  evaluator.addExpressionFunction("asyncTrue", async () => {
475
485
  return Promise.resolve(true);
package/src/player.ts CHANGED
@@ -346,47 +346,46 @@ export class Player {
346
346
  }
347
347
  });
348
348
 
349
- // Tap for synchronous action states
350
- flow.hooks.afterTransition.tap("player", (flowInstance) => {
351
- const value = flowInstance.currentState?.value;
352
- if (value && value.state_type === "ACTION") {
353
- const { exp } = value;
354
- const result = expressionEvaluator.evaluate(exp);
355
- if (isPromiseLike(result)) {
356
- this.logger.warn(
357
- "Async expression used as return value in in non-async context, transitioning with '*' value",
358
- );
359
- }
360
- flowController?.transition(String(result));
361
- }
362
-
363
- expressionEvaluator.reset();
364
- });
365
-
366
- // Tap for async action states
367
- flow.hooks.afterTransition.tap("player", async (flowInstance) => {
349
+ // Tap for action states
350
+ flow.hooks.afterTransition.tap("player-action-states", (flowInstance) => {
368
351
  const value = flowInstance.currentState?.value;
369
352
  if (value && value.state_type === "ASYNC_ACTION") {
370
353
  const { exp } = value;
354
+ // defer async execution to next tick to allow transition to settle
371
355
  try {
372
- let result = expressionEvaluator.evaluateAsync(exp);
356
+ const result = expressionEvaluator.evaluateAsync(exp);
373
357
  if (isPromiseLike(result)) {
374
358
  if (value.await) {
375
- result = await result;
359
+ queueMicrotask(() => {
360
+ result
361
+ .then((r) => flowController?.transition(String(r)))
362
+ .catch(flowResultDeferred.reject);
363
+ });
376
364
  } else {
377
365
  this.logger.warn(
378
366
  "Unawaited promise used as return value in in non-async context, transitioning with '*' value",
379
367
  );
368
+ flowController?.transition(String(result));
380
369
  }
381
370
  } else {
382
371
  this.logger.warn(
383
372
  "Non async expression used in async action node",
384
373
  );
374
+ flowController?.transition(String(result));
385
375
  }
386
- flowController?.transition(String(result));
387
376
  } catch (e) {
388
377
  flowResultDeferred.reject(e);
389
378
  }
379
+ } else if (value && value.state_type === "ACTION") {
380
+ // handle sync actions
381
+ const { exp } = value;
382
+ const result = expressionEvaluator.evaluate(exp);
383
+ if (isPromiseLike(result)) {
384
+ this.logger.warn(
385
+ "Async expression used as return value in in non-async context, transitioning with '*' value",
386
+ );
387
+ }
388
+ flowController?.transition(String(result));
390
389
  }
391
390
 
392
391
  expressionEvaluator.reset();
@@ -3,11 +3,12 @@ import type { Navigation, NavigationFlowEndState } from "@player-ui/types";
3
3
  import type { Logger } from "../../logger";
4
4
  import type { TransitionOptions } from "./flow";
5
5
  import { FlowInstance } from "./flow";
6
+ export interface FlowControllerHooks {
7
+ flow: SyncHook<[FlowInstance], Record<string, any>>;
8
+ }
6
9
  /** A manager for the navigation section of a Content blob */
7
10
  export declare class FlowController {
8
- readonly hooks: {
9
- flow: SyncHook<[FlowInstance], Record<string, any>>;
10
- };
11
+ readonly hooks: FlowControllerHooks;
11
12
  private readonly log?;
12
13
  private navigation;
13
14
  private navStack;
@@ -1,5 +1,5 @@
1
1
  import { SyncBailHook, SyncHook, SyncWaterfallHook } from "tapable-ts";
2
- import type { NavigationFlow, NavigationFlowState, NavigationFlowEndState } from "@player-ui/types";
2
+ import type { NavigationFlow, NavigationFlowState, NavigationFlowEndState, NavigationFlowActionState, NavigationFlowAsyncActionState, NavigationFlowExternalState, NavigationFlowFlowState, NavigationFlowViewState } from "@player-ui/types";
3
3
  import type { Logger } from "../../logger";
4
4
  export interface NamedState {
5
5
  /** The name of the navigation node */
@@ -12,6 +12,35 @@ export interface TransitionOptions {
12
12
  force?: boolean;
13
13
  }
14
14
  export type TransitionFunction = (name: string, options?: TransitionOptions) => void;
15
+ export interface FlowInstanceHooks {
16
+ beforeStart: SyncBailHook<[
17
+ NavigationFlow
18
+ ], NavigationFlow, Record<string, any>>;
19
+ /** A callback when the onStart node was present */
20
+ onStart: SyncHook<[any], Record<string, any>>;
21
+ /** A callback when the onEnd node was present */
22
+ onEnd: SyncHook<[any], Record<string, any>>;
23
+ /** A hook to intercept and block a transition */
24
+ skipTransition: SyncBailHook<[
25
+ NamedState | undefined
26
+ ], boolean | undefined, Record<string, any>>;
27
+ /** A chance to manipulate the flow-node used to calculate the given transition used */
28
+ beforeTransition: SyncWaterfallHook<[
29
+ (NavigationFlowViewState | NavigationFlowFlowState | NavigationFlowActionState | NavigationFlowAsyncActionState | NavigationFlowExternalState),
30
+ string
31
+ ], Record<string, any>>;
32
+ /** A chance to manipulate the flow-node calculated after a transition */
33
+ resolveTransitionNode: SyncWaterfallHook<[
34
+ NavigationFlowState
35
+ ], Record<string, any>>;
36
+ /** A callback when a transition from 1 state to another was made */
37
+ transition: SyncHook<[
38
+ NamedState | undefined,
39
+ NamedState
40
+ ], Record<string, any>>;
41
+ /** A callback to run actions after a transition occurs */
42
+ afterTransition: SyncHook<[FlowInstance], Record<string, any>>;
43
+ }
15
44
  /** The Content navigation state machine */
16
45
  export declare class FlowInstance {
17
46
  private flow;
@@ -21,23 +50,7 @@ export declare class FlowInstance {
21
50
  private flowPromise?;
22
51
  readonly id: string;
23
52
  currentState?: NamedState;
24
- readonly hooks: {
25
- beforeStart: SyncBailHook<[NavigationFlow], NavigationFlow, Record<string, any>>;
26
- /** A callback when the onStart node was present */
27
- onStart: SyncHook<[any], Record<string, any>>;
28
- /** A callback when the onEnd node was present */
29
- onEnd: SyncHook<[any], Record<string, any>>;
30
- /** A hook to intercept and block a transition */
31
- skipTransition: SyncBailHook<[NamedState | undefined], boolean | undefined, Record<string, any>>;
32
- /** A chance to manipulate the flow-node used to calculate the given transition used */
33
- beforeTransition: SyncWaterfallHook<[import("@player-ui/types").NavigationFlowViewState | import("@player-ui/types").NavigationFlowFlowState | import("@player-ui/types").NavigationFlowActionState | import("@player-ui/types").NavigationFlowAsyncActionState | import("@player-ui/types").NavigationFlowExternalState, string], Record<string, any>>;
34
- /** A chance to manipulate the flow-node calculated after a transition */
35
- resolveTransitionNode: SyncWaterfallHook<[NavigationFlowState], Record<string, any>>;
36
- /** A callback when a transition from 1 state to another was made */
37
- transition: SyncHook<[NamedState | undefined, NamedState], Record<string, any>>;
38
- /** A callback to run actions after a transition occurs */
39
- afterTransition: SyncHook<[FlowInstance], Record<string, any>>;
40
- };
53
+ readonly hooks: FlowInstanceHooks;
41
54
  constructor(id: string, flow: NavigationFlow, options?: {
42
55
  /** Logger instance to use */
43
56
  logger?: Logger;