@player-ui/async-node-plugin 0.13.0-next.2 → 0.13.0-next.4

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.
@@ -1,10 +1,43 @@
1
- import { expect, test, describe } from "vitest";
2
- import { Node, InProgressState } from "@player-ui/player";
1
+ import { expect, test, describe, vi, beforeEach } from "vitest";
2
+ import {
3
+ Node,
4
+ InProgressState,
5
+ ErrorState,
6
+ PlayerPlugin,
7
+ AssetTransformCorePlugin,
8
+ BeforeTransformFunction,
9
+ } from "@player-ui/player";
3
10
  import { Player, Parser } from "@player-ui/player";
4
11
  import { waitFor } from "@testing-library/react";
5
- import { AsyncNodePlugin, AsyncNodePluginPlugin } from "../index";
6
- import { ReferenceAssetsPlugin } from "@player-ui/reference-assets-plugin";
12
+ import {
13
+ AsyncNodePlugin,
14
+ AsyncNodePluginPlugin,
15
+ asyncTransform,
16
+ } from "../index";
7
17
  import { CheckPathPlugin } from "@player-ui/check-path-plugin";
18
+ import { Registry } from "@player-ui/partial-match-registry";
19
+
20
+ const transform: BeforeTransformFunction = (asset) => {
21
+ const newAsset = asset.children?.[0]?.value;
22
+
23
+ if (!newAsset) {
24
+ return asyncTransform(asset.value.id, "collection");
25
+ }
26
+ return asyncTransform(asset.value.id, "collection", newAsset);
27
+ };
28
+
29
+ const transformPlugin = new AssetTransformCorePlugin(
30
+ new Registry([[{ type: "chat-message" }, { beforeResolve: transform }]]),
31
+ );
32
+
33
+ class TestAsyncPlugin implements PlayerPlugin {
34
+ name = "test-async";
35
+ apply(player: Player) {
36
+ player.hooks.view.tap("test-async", (view) => {
37
+ transformPlugin.apply(view);
38
+ });
39
+ }
40
+ }
8
41
 
9
42
  describe("view", () => {
10
43
  const basicFRFWithActions = {
@@ -734,6 +767,84 @@ describe("view", () => {
734
767
  });
735
768
  });
736
769
 
770
+ describe("Async Node Error Handling", () => {
771
+ let failingAsyncNodePlugin: AsyncNodePlugin = new AsyncNodePlugin({});
772
+ const onAsyncNodeErrorCallback = vi.fn();
773
+
774
+ beforeEach(() => {
775
+ onAsyncNodeErrorCallback.mockReset();
776
+ const failingHandler = vi.fn();
777
+ failingHandler.mockRejectedValue("Promise Rejected");
778
+
779
+ failingAsyncNodePlugin = new AsyncNodePlugin(
780
+ {
781
+ plugins: [new AsyncNodePluginPlugin()],
782
+ },
783
+ failingHandler,
784
+ );
785
+
786
+ failingAsyncNodePlugin.hooks.onAsyncNodeError.tap(
787
+ "test",
788
+ onAsyncNodeErrorCallback,
789
+ );
790
+ });
791
+
792
+ test("should replace the async node with the result from the onAsyncNodeError hook when there is an error handling the async node", async () => {
793
+ onAsyncNodeErrorCallback.mockReturnValue({
794
+ asset: {
795
+ id: "async-text",
796
+ type: "text",
797
+ value: "Fallback Text",
798
+ },
799
+ });
800
+
801
+ const player = new Player({ plugins: [failingAsyncNodePlugin] });
802
+ player.start(basicFRFWithActions as any);
803
+
804
+ await waitFor(() => {
805
+ expect(onAsyncNodeErrorCallback).toHaveBeenCalledWith(
806
+ new Error("Promise Rejected"),
807
+ expect.anything(),
808
+ );
809
+
810
+ const playerState = player.getState();
811
+ expect(playerState.status).toBe("in-progress");
812
+ const inProgressState = playerState as InProgressState;
813
+ const lastViewUpdate =
814
+ inProgressState.controllers.view.currentView?.lastUpdate;
815
+
816
+ expect(lastViewUpdate?.actions[1]).toStrictEqual({
817
+ asset: {
818
+ id: "async-text",
819
+ type: "text",
820
+ value: "Fallback Text",
821
+ },
822
+ });
823
+ });
824
+ });
825
+
826
+ test("should bubble up the error and cause player to fail when there is an error handling the async node and the onAsyncNodeError hook does not produce a fallback", async () => {
827
+ onAsyncNodeErrorCallback.mockReturnValue(undefined);
828
+
829
+ const player = new Player({ plugins: [failingAsyncNodePlugin] });
830
+ player.start(basicFRFWithActions as any).catch(() => {
831
+ /** Purposefully failing player in this test so catching the unresolved exception suppresses warnings from vitest */
832
+ });
833
+
834
+ await waitFor(() => {
835
+ expect(onAsyncNodeErrorCallback).toHaveBeenCalledWith(
836
+ new Error("Promise Rejected"),
837
+ expect.anything(),
838
+ );
839
+
840
+ const playerState = player.getState();
841
+ expect(playerState.status).toBe("error");
842
+ const errorState = playerState as ErrorState;
843
+ expect(errorState.error.message).toBe("Promise Rejected");
844
+ });
845
+ });
846
+ });
847
+
737
848
  test("chat-message asset - replaces async nodes with multi node flattened", async () => {
738
849
  const plugin = new AsyncNodePlugin({
739
850
  plugins: [new AsyncNodePluginPlugin()],
@@ -749,7 +860,7 @@ describe("view", () => {
749
860
 
750
861
  let updateNumber = 0;
751
862
 
752
- const plugins = [plugin, new ReferenceAssetsPlugin()];
863
+ const plugins = [plugin, new TestAsyncPlugin()];
753
864
 
754
865
  const player = new Player({
755
866
  plugins: plugins,
@@ -821,7 +932,7 @@ describe("view", () => {
821
932
 
822
933
  let updateNumber = 0;
823
934
 
824
- const plugins = [plugin, new ReferenceAssetsPlugin()];
935
+ const plugins = [plugin, new TestAsyncPlugin()];
825
936
 
826
937
  const player = new Player({
827
938
  plugins: plugins,
@@ -886,7 +997,7 @@ describe("view", () => {
886
997
  let updateNumber = 0;
887
998
 
888
999
  const player = new Player({
889
- plugins: [plugin, new ReferenceAssetsPlugin()],
1000
+ plugins: [plugin, new TestAsyncPlugin()],
890
1001
  });
891
1002
 
892
1003
  player.hooks.viewController.tap("async-node-test", (vc) => {
@@ -953,7 +1064,7 @@ describe("view", () => {
953
1064
 
954
1065
  let updateNumber = 0;
955
1066
 
956
- const plugins = [plugin, new ReferenceAssetsPlugin()];
1067
+ const plugins = [plugin, new TestAsyncPlugin()];
957
1068
 
958
1069
  const player = new Player({
959
1070
  plugins: plugins,
@@ -1051,7 +1162,7 @@ describe("view", () => {
1051
1162
 
1052
1163
  const checkPathPlugin = new CheckPathPlugin();
1053
1164
 
1054
- const plugins = [plugin, new ReferenceAssetsPlugin(), checkPathPlugin];
1165
+ const plugins = [plugin, new TestAsyncPlugin(), checkPathPlugin];
1055
1166
 
1056
1167
  const player = new Player({
1057
1168
  plugins: plugins,
@@ -1240,6 +1351,160 @@ describe("view", () => {
1240
1351
  ],
1241
1352
  });
1242
1353
  });
1354
+
1355
+ describe("multi-view cache", () => {
1356
+ let asyncNodePlugin: AsyncNodePlugin = new AsyncNodePlugin({});
1357
+ const deferHandler = vi.fn();
1358
+ let deferredResolve = (value: any) => {};
1359
+
1360
+ beforeEach(() => {
1361
+ deferHandler.mockReset();
1362
+ deferHandler.mockImplementation(
1363
+ () =>
1364
+ new Promise((res) => {
1365
+ deferredResolve = res;
1366
+ }),
1367
+ );
1368
+
1369
+ asyncNodePlugin = new AsyncNodePlugin(
1370
+ {
1371
+ plugins: [new AsyncNodePluginPlugin()],
1372
+ },
1373
+ deferHandler,
1374
+ );
1375
+ });
1376
+
1377
+ test("clear cache between views", async () => {
1378
+ const player = new Player({ plugins: [asyncNodePlugin] });
1379
+ player.start(basicFRFWithActions as any);
1380
+
1381
+ await waitFor(() => {
1382
+ expect(deferHandler).toHaveBeenCalledOnce();
1383
+ });
1384
+
1385
+ deferredResolve({
1386
+ asset: {
1387
+ id: "async-text",
1388
+ type: "text",
1389
+ value: "Fallback Text",
1390
+ },
1391
+ });
1392
+
1393
+ await waitFor(() => {
1394
+ const playerState = player.getState();
1395
+ expect(playerState.status).toBe("in-progress");
1396
+ const inProgressState = playerState as InProgressState;
1397
+ const lastViewUpdate =
1398
+ inProgressState.controllers.view.currentView?.lastUpdate;
1399
+
1400
+ expect(lastViewUpdate?.actions[1]).toStrictEqual({
1401
+ asset: {
1402
+ id: "async-text",
1403
+ type: "text",
1404
+ value: "Fallback Text",
1405
+ },
1406
+ });
1407
+ });
1408
+
1409
+ deferHandler.mockClear();
1410
+ player.start(basicFRFWithActions as any);
1411
+
1412
+ await waitFor(() => {
1413
+ expect(deferHandler).toHaveBeenCalledOnce();
1414
+ });
1415
+
1416
+ deferredResolve({
1417
+ asset: {
1418
+ id: "async-text",
1419
+ type: "text",
1420
+ value: "New Fallback Text",
1421
+ },
1422
+ });
1423
+
1424
+ await waitFor(() => {
1425
+ const playerState = player.getState();
1426
+ expect(playerState.status).toBe("in-progress");
1427
+ const inProgressState = playerState as InProgressState;
1428
+ const lastViewUpdate =
1429
+ inProgressState.controllers.view.currentView?.lastUpdate;
1430
+
1431
+ expect(lastViewUpdate?.actions[1]).toStrictEqual({
1432
+ asset: {
1433
+ id: "async-text",
1434
+ type: "text",
1435
+ value: "New Fallback Text",
1436
+ },
1437
+ });
1438
+ });
1439
+ });
1440
+ });
1441
+
1442
+ // For tests dealing with the startup and cleanup of promises for async nodes.
1443
+ describe("Promise Management", () => {
1444
+ test("should not start async node handling if process has already started", async () => {
1445
+ const plugin = new AsyncNodePlugin({
1446
+ plugins: [new AsyncNodePluginPlugin()],
1447
+ });
1448
+
1449
+ // Create a promise that won't be resolved.
1450
+ const asyncTapFn = vi.fn();
1451
+ asyncTapFn.mockReturnValue(new Promise(() => {}));
1452
+
1453
+ plugin.hooks.onAsyncNode.tap("test", asyncTapFn);
1454
+
1455
+ const player = new Player({ plugins: [plugin] });
1456
+ player.start(basicFRFWithActions as any);
1457
+
1458
+ await vi.waitFor(() => {
1459
+ expect(asyncTapFn).toHaveBeenCalledOnce();
1460
+ });
1461
+
1462
+ // Clear the one call since it has already been checked.
1463
+ asyncTapFn.mockClear();
1464
+ const playerState = player.getState();
1465
+ expect(playerState.status).toBe("in-progress");
1466
+ // force an update while ignoring the cache.
1467
+ (playerState as InProgressState).controllers.view.currentView?.update();
1468
+
1469
+ // Should not be called again.
1470
+ expect(
1471
+ vi.waitFor(() => {
1472
+ expect(asyncTapFn).toHaveBeenCalledOnce();
1473
+ }),
1474
+ ).rejects.toThrowError();
1475
+ });
1476
+
1477
+ test("should allow for handling to start again if the promise completes", async () => {
1478
+ const plugin = new AsyncNodePlugin({
1479
+ plugins: [new AsyncNodePluginPlugin()],
1480
+ });
1481
+
1482
+ // Create a promise that won't be resolved.
1483
+ const asyncTapFn = vi.fn();
1484
+ asyncTapFn.mockResolvedValue(null);
1485
+
1486
+ plugin.hooks.onAsyncNode.tap("test", asyncTapFn);
1487
+
1488
+ const player = new Player({ plugins: [plugin] });
1489
+ player.start(basicFRFWithActions as any);
1490
+
1491
+ await vi.waitFor(() => {
1492
+ expect(asyncTapFn).toHaveBeenCalledOnce();
1493
+ });
1494
+
1495
+ // Clear the one call since it has already been checked.
1496
+ asyncTapFn.mockClear();
1497
+ const playerState = player.getState();
1498
+ expect(playerState.status).toBe("in-progress");
1499
+ // force an update while ignoring the cache.
1500
+ (playerState as InProgressState).controllers.view.currentView?.update();
1501
+
1502
+ // Should be called again.
1503
+ vi.waitFor(() => {
1504
+ expect(asyncTapFn).toHaveBeenCalledOnce();
1505
+ });
1506
+ });
1507
+ });
1243
1508
  });
1244
1509
 
1245
1510
  describe("parser", () => {
package/src/index.ts CHANGED
@@ -11,13 +11,25 @@ import type {
11
11
  Resolver,
12
12
  Resolve,
13
13
  } from "@player-ui/player";
14
- import { AsyncParallelBailHook } from "tapable-ts";
14
+ import { AsyncParallelBailHook, SyncBailHook } from "tapable-ts";
15
15
  import queueMicrotask from "queue-microtask";
16
16
  import { omit } from "timm";
17
17
 
18
18
  export * from "./types";
19
19
  export * from "./transform";
20
20
 
21
+ /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
22
+ * This object should be setup once per ViewInstance to keep any cached info just for that view to avoid conflicts of shared async node ids across different view states.
23
+ */
24
+ type AsyncPluginContext = {
25
+ /** Map of async node id to resolved content */
26
+ nodeResolveCache: Map<string, any>;
27
+ /** The view instance this context is attached to. */
28
+ view: ViewInstance;
29
+ /** Map of async node id to promises being used to resolve them */
30
+ inProgressNodes: Set<string>;
31
+ };
32
+
21
33
  export interface AsyncNodePluginOptions {
22
34
  /** A set of plugins to load */
23
35
  plugins?: AsyncNodeViewPlugin[];
@@ -34,12 +46,21 @@ export type AsyncHandler = (
34
46
  callback?: (result: any) => void,
35
47
  ) => Promise<any>;
36
48
 
49
+ /** Hook declaration for the AsyncNodePlugin */
50
+ type AsyncNodeHooks = {
51
+ /** Async hook to get content for an async node */
52
+ onAsyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any>;
53
+ /** Sync hook to manage errors coming from the onAsyncNode hook. Return a fallback node or null to render a fallback. The first argument of passed in the call is the error thrown. */
54
+ onAsyncNodeError: SyncBailHook<[Error, Node.Async], any>;
55
+ };
56
+
37
57
  /**
38
58
  * Async node plugin used to resolve async nodes in the content
39
59
  * If an async node is present, allow users to provide a replacement node to be rendered when ready
40
60
  */
41
61
  export class AsyncNodePlugin implements PlayerPlugin {
42
62
  private plugins: AsyncNodeViewPlugin[] | undefined;
63
+ private playerInstance: Player | undefined;
43
64
 
44
65
  constructor(options: AsyncNodePluginOptions, asyncHandler?: AsyncHandler) {
45
66
  if (options?.plugins) {
@@ -59,16 +80,20 @@ export class AsyncNodePlugin implements PlayerPlugin {
59
80
  }
60
81
  }
61
82
 
62
- public readonly hooks = {
63
- onAsyncNode: new AsyncParallelBailHook<
64
- [Node.Async, (result: any) => void],
65
- any
66
- >(),
83
+ public readonly hooks: AsyncNodeHooks = {
84
+ onAsyncNode: new AsyncParallelBailHook(),
85
+ onAsyncNodeError: new SyncBailHook(),
67
86
  };
68
87
 
88
+ getPlayerInstance(): Player | undefined {
89
+ return this.playerInstance;
90
+ }
91
+
69
92
  name = "AsyncNode";
70
93
 
71
- apply(player: Player) {
94
+ apply(player: Player): void {
95
+ this.playerInstance = player;
96
+
72
97
  player.hooks.viewController.tap(this.name, (viewController) => {
73
98
  viewController.hooks.view.tap(this.name, (view) => {
74
99
  this.plugins?.forEach((plugin) => {
@@ -80,40 +105,51 @@ export class AsyncNodePlugin implements PlayerPlugin {
80
105
  }
81
106
 
82
107
  export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
83
- public asyncNode = new AsyncParallelBailHook<
108
+ public asyncNode: AsyncParallelBailHook<
84
109
  [Node.Async, (result: any) => void],
85
110
  any
86
- >();
111
+ > = new AsyncParallelBailHook();
87
112
  private basePlugin: AsyncNodePlugin | undefined;
88
113
 
89
114
  name = "AsyncNode";
90
115
 
91
- private resolvedMapping = new Map<string, any>();
92
-
93
- private currentView: ViewInstance | undefined;
94
-
95
116
  /**
96
- * Updates the node asynchronously based on the result provided.
97
- * This method is responsible for handling the update logic of asynchronous nodes.
98
- * It checks if the node needs to be updated based on the new result and updates the mapping accordingly.
99
- * If an update is necessary, it triggers an asynchronous update on the view.
117
+ * Parses the node from the result and triggers an asynchronous view update if necessary.
100
118
  * @param node The asynchronous node that might be updated.
101
119
  * @param result The result obtained from resolving the async node. This could be any data structure or value.
102
120
  * @param options Options provided for node resolution, including a potential parseNode function to process the result.
103
121
  * @param view The view instance where the node resides. This can be undefined if the view is not currently active.
104
122
  */
105
- private handleAsyncUpdate(
123
+ private parseNodeAndUpdate(
106
124
  node: Node.Async,
125
+ context: AsyncPluginContext,
107
126
  result: any,
108
127
  options: Resolve.NodeResolveOptions,
109
- view: ViewInstance | undefined,
110
128
  ) {
111
129
  const parsedNode =
112
130
  options.parseNode && result ? options.parseNode(result) : undefined;
113
131
 
114
- if (this.resolvedMapping.get(node.id) !== parsedNode) {
115
- this.resolvedMapping.set(node.id, parsedNode ? parsedNode : node);
116
- view?.updateAsync();
132
+ this.handleAsyncUpdate(node, context, parsedNode);
133
+ }
134
+
135
+ /**
136
+ * Updates the node asynchronously based on the result provided.
137
+ * This method is responsible for handling the update logic of asynchronous nodes.
138
+ * It checks if the node needs to be updated based on the new result and updates the mapping accordingly.
139
+ * If an update is necessary, it triggers an asynchronous update on the view.
140
+ * @param node The asynchronous node that might be updated.
141
+ * @param newNode The new node to replace the async node.
142
+ * @param view The view instance where the node resides. This can be undefined if the view is not currently active.
143
+ */
144
+ private handleAsyncUpdate(
145
+ node: Node.Async,
146
+ context: AsyncPluginContext,
147
+ newNode?: Node.Node | null,
148
+ ) {
149
+ const { nodeResolveCache, view } = context;
150
+ if (nodeResolveCache.get(node.id) !== newNode) {
151
+ nodeResolveCache.set(node.id, newNode ? newNode : node);
152
+ view.updateAsync();
117
153
  }
118
154
  }
119
155
 
@@ -123,36 +159,72 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
123
159
  * @param resolver The resolver instance to attach the hook to.
124
160
  * @param view
125
161
  */
126
- applyResolver(resolver: Resolver) {
162
+ applyResolver(resolver: Resolver, context: AsyncPluginContext): void {
127
163
  resolver.hooks.beforeResolve.tap(this.name, (node, options) => {
128
- let resolvedNode;
129
- if (this.isAsync(node)) {
130
- const mappedValue = this.resolvedMapping.get(node.id);
131
- if (mappedValue) {
132
- resolvedNode = mappedValue;
133
- }
134
- } else {
135
- resolvedNode = null;
164
+ if (!this.isAsync(node)) {
165
+ return node;
136
166
  }
137
167
 
138
- const newNode = resolvedNode || node;
139
- if (!resolvedNode && node?.type === NodeType.Async) {
140
- queueMicrotask(async () => {
141
- const result = await this.basePlugin?.hooks.onAsyncNode.call(
142
- node,
143
- (result) => {
144
- this.handleAsyncUpdate(node, result, options, this.currentView);
145
- },
146
- );
147
- this.handleAsyncUpdate(node, result, options, this.currentView);
148
- });
168
+ const resolvedNode = context.nodeResolveCache.get(node.id);
169
+ if (resolvedNode !== undefined) {
170
+ return resolvedNode;
171
+ }
149
172
 
173
+ if (context.inProgressNodes.has(node.id)) {
150
174
  return node;
151
175
  }
152
- return newNode;
176
+
177
+ // Track that the node is in progress.
178
+ context.inProgressNodes.add(node.id);
179
+ queueMicrotask(() => {
180
+ this.runAsyncNode(node, context, options).finally();
181
+ });
182
+
183
+ return node;
153
184
  });
154
185
  }
155
186
 
187
+ private async runAsyncNode(
188
+ node: Node.Async,
189
+ context: AsyncPluginContext,
190
+ options: Resolve.NodeResolveOptions,
191
+ ) {
192
+ try {
193
+ const result = await this.basePlugin?.hooks.onAsyncNode.call(
194
+ node,
195
+ (result) => {
196
+ this.parseNodeAndUpdate(node, context, result, options);
197
+ },
198
+ );
199
+
200
+ // Stop tracking before the next update is triggered
201
+ context.inProgressNodes.delete(node.id);
202
+ this.parseNodeAndUpdate(node, context, result, options);
203
+ } catch (e: unknown) {
204
+ const error = e instanceof Error ? e : new Error(String(e));
205
+ const result = this.basePlugin?.hooks.onAsyncNodeError.call(error, node);
206
+
207
+ if (result === undefined) {
208
+ const playerState = this.basePlugin?.getPlayerInstance()?.getState();
209
+
210
+ if (playerState?.status === "in-progress") {
211
+ playerState.fail(error);
212
+ }
213
+
214
+ return;
215
+ }
216
+
217
+ options.logger?.error(
218
+ "Async node handling failed and resolved with a fallback. Error:",
219
+ error,
220
+ );
221
+
222
+ // Stop tracking before the next update is triggered
223
+ context.inProgressNodes.delete(node.id);
224
+ this.parseNodeAndUpdate(node, context, result, options);
225
+ }
226
+ }
227
+
156
228
  private isAsync(node: Node.Node | null): node is Node.Async {
157
229
  return node?.type === NodeType.Async;
158
230
  }
@@ -161,7 +233,7 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
161
233
  return obj && Object.prototype.hasOwnProperty.call(obj, "async");
162
234
  }
163
235
 
164
- applyParser(parser: Parser) {
236
+ applyParser(parser: Parser): void {
165
237
  parser.hooks.parseNode.tap(
166
238
  this.name,
167
239
  (
@@ -209,9 +281,16 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
209
281
  }
210
282
 
211
283
  apply(view: ViewInstance): void {
212
- this.currentView = view;
284
+ const context: AsyncPluginContext = {
285
+ nodeResolveCache: new Map(),
286
+ inProgressNodes: new Set(),
287
+ view,
288
+ };
289
+
213
290
  view.hooks.parser.tap("async", this.applyParser.bind(this));
214
- view.hooks.resolver.tap("async", this.applyResolver.bind(this));
291
+ view.hooks.resolver.tap("async", (resolver) => {
292
+ this.applyResolver(resolver, context);
293
+ });
215
294
  }
216
295
 
217
296
  applyPlugin(asyncNodePlugin: AsyncNodePlugin): void {
package/types/index.d.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import type { Player, PlayerPlugin, Node, ViewInstance, Parser, ViewPlugin, Resolver } from "@player-ui/player";
2
- import { AsyncParallelBailHook } from "tapable-ts";
2
+ import { AsyncParallelBailHook, SyncBailHook } from "tapable-ts";
3
3
  export * from "./types";
4
4
  export * from "./transform";
5
+ /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
6
+ * This object should be setup once per ViewInstance to keep any cached info just for that view to avoid conflicts of shared async node ids across different view states.
7
+ */
8
+ type AsyncPluginContext = {
9
+ /** Map of async node id to resolved content */
10
+ nodeResolveCache: Map<string, any>;
11
+ /** The view instance this context is attached to. */
12
+ view: ViewInstance;
13
+ /** Map of async node id to promises being used to resolve them */
14
+ inProgressNodes: Set<string>;
15
+ };
5
16
  export interface AsyncNodePluginOptions {
6
17
  /** A set of plugins to load */
7
18
  plugins?: AsyncNodeViewPlugin[];
@@ -12,33 +23,48 @@ export interface AsyncNodeViewPlugin extends ViewPlugin {
12
23
  asyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any>;
13
24
  }
14
25
  export type AsyncHandler = (node: Node.Async, callback?: (result: any) => void) => Promise<any>;
26
+ /** Hook declaration for the AsyncNodePlugin */
27
+ type AsyncNodeHooks = {
28
+ /** Async hook to get content for an async node */
29
+ onAsyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any>;
30
+ /** Sync hook to manage errors coming from the onAsyncNode hook. Return a fallback node or null to render a fallback. The first argument of passed in the call is the error thrown. */
31
+ onAsyncNodeError: SyncBailHook<[Error, Node.Async], any>;
32
+ };
15
33
  /**
16
34
  * Async node plugin used to resolve async nodes in the content
17
35
  * If an async node is present, allow users to provide a replacement node to be rendered when ready
18
36
  */
19
37
  export declare class AsyncNodePlugin implements PlayerPlugin {
20
38
  private plugins;
39
+ private playerInstance;
21
40
  constructor(options: AsyncNodePluginOptions, asyncHandler?: AsyncHandler);
22
- readonly hooks: {
23
- onAsyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any, Record<string, any>>;
24
- };
41
+ readonly hooks: AsyncNodeHooks;
42
+ getPlayerInstance(): Player | undefined;
25
43
  name: string;
26
44
  apply(player: Player): void;
27
45
  }
28
46
  export declare class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
29
- asyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any, Record<string, any>>;
47
+ asyncNode: AsyncParallelBailHook<[
48
+ Node.Async,
49
+ (result: any) => void
50
+ ], any>;
30
51
  private basePlugin;
31
52
  name: string;
32
- private resolvedMapping;
33
- private currentView;
53
+ /**
54
+ * Parses the node from the result and triggers an asynchronous view update if necessary.
55
+ * @param node The asynchronous node that might be updated.
56
+ * @param result The result obtained from resolving the async node. This could be any data structure or value.
57
+ * @param options Options provided for node resolution, including a potential parseNode function to process the result.
58
+ * @param view The view instance where the node resides. This can be undefined if the view is not currently active.
59
+ */
60
+ private parseNodeAndUpdate;
34
61
  /**
35
62
  * Updates the node asynchronously based on the result provided.
36
63
  * This method is responsible for handling the update logic of asynchronous nodes.
37
64
  * It checks if the node needs to be updated based on the new result and updates the mapping accordingly.
38
65
  * If an update is necessary, it triggers an asynchronous update on the view.
39
66
  * @param node The asynchronous node that might be updated.
40
- * @param result The result obtained from resolving the async node. This could be any data structure or value.
41
- * @param options Options provided for node resolution, including a potential parseNode function to process the result.
67
+ * @param newNode The new node to replace the async node.
42
68
  * @param view The view instance where the node resides. This can be undefined if the view is not currently active.
43
69
  */
44
70
  private handleAsyncUpdate;
@@ -48,7 +74,8 @@ export declare class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
48
74
  * @param resolver The resolver instance to attach the hook to.
49
75
  * @param view
50
76
  */
51
- applyResolver(resolver: Resolver): void;
77
+ applyResolver(resolver: Resolver, context: AsyncPluginContext): void;
78
+ private runAsyncNode;
52
79
  private isAsync;
53
80
  private isDeterminedAsync;
54
81
  applyParser(parser: Parser): void;