@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.
- package/dist/AsyncNodePlugin.native.js +114 -48
- package/dist/AsyncNodePlugin.native.js.map +1 -1
- package/dist/cjs/index.cjs +72 -32
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.legacy-esm.js +73 -33
- package/dist/index.mjs +73 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/index.test.ts +274 -9
- package/src/index.ts +125 -46
- package/types/index.d.ts +37 -10
|
@@ -1,10 +1,43 @@
|
|
|
1
|
-
import { expect, test, describe } from "vitest";
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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<[
|
|
47
|
+
asyncNode: AsyncParallelBailHook<[
|
|
48
|
+
Node.Async,
|
|
49
|
+
(result: any) => void
|
|
50
|
+
], any>;
|
|
30
51
|
private basePlugin;
|
|
31
52
|
name: string;
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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;
|