@player-ui/async-node-plugin 0.13.0-next.1 → 0.13.0-next.3
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 +141 -136
- package/dist/AsyncNodePlugin.native.js.map +1 -1
- package/dist/cjs/index.cjs +64 -33
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.legacy-esm.js +65 -34
- package/dist/index.mjs +65 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/index.bench.ts +135 -0
- package/src/__tests__/index.test.ts +207 -9
- package/src/index.ts +111 -48
- package/types/index.d.ts +35 -10
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { bench, BenchOptions, describe } from "vitest";
|
|
2
|
+
import { AsyncNodePlugin, AsyncNodePluginPlugin } from "..";
|
|
3
|
+
import { Flow, Player } from "@player-ui/player";
|
|
4
|
+
|
|
5
|
+
const asyncBenchFlow: Flow = {
|
|
6
|
+
id: "test-flow",
|
|
7
|
+
views: [
|
|
8
|
+
{
|
|
9
|
+
id: "my-view",
|
|
10
|
+
type: "view",
|
|
11
|
+
values: [
|
|
12
|
+
{
|
|
13
|
+
id: "nodeId",
|
|
14
|
+
async: "true",
|
|
15
|
+
flatten: true,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
navigation: {
|
|
21
|
+
BEGIN: "FLOW_1",
|
|
22
|
+
FLOW_1: {
|
|
23
|
+
startState: "VIEW_1",
|
|
24
|
+
VIEW_1: {
|
|
25
|
+
state_type: "VIEW",
|
|
26
|
+
ref: "my-view",
|
|
27
|
+
transitions: {
|
|
28
|
+
"*": "END_DONE",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
END_DONE: {
|
|
32
|
+
state_type: "END",
|
|
33
|
+
outcome: "done",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Benchmark tests for async node resolution. Each test spins up player and resolves all but the last async node to be setup.
|
|
40
|
+
// This is to make tests results easier to compare. If test results across different node counts are similar than we know that resolving additional async nodes will not have significant performance impact.
|
|
41
|
+
describe("async node benchmarks", () => {
|
|
42
|
+
const asyncNodes = [1, 5, 10, 50, 100];
|
|
43
|
+
|
|
44
|
+
asyncNodes.forEach((nodeCount) => {
|
|
45
|
+
// Promise for when player reaches a completed state.
|
|
46
|
+
let playerCompletePromise: Promise<unknown>;
|
|
47
|
+
// Function to resolve the async node. Resolves the promise for the `onAsyncNode` hook.
|
|
48
|
+
let resolveAsyncNode: () => void;
|
|
49
|
+
|
|
50
|
+
// Setup function for spinning up player and setting up the above promise and function.
|
|
51
|
+
// Using a setup function also takes all the overhead of the setup itself out of the perf benchmark.
|
|
52
|
+
const setupPlayer = () => {
|
|
53
|
+
const asyncNodePlugin = new AsyncNodePlugin({
|
|
54
|
+
plugins: [new AsyncNodePluginPlugin()],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let completeSetup: (value?: unknown) => void = () => {};
|
|
58
|
+
const setupPromise = new Promise((res) => {
|
|
59
|
+
completeSetup = res;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let lastCreatedNodeIndex = -1;
|
|
63
|
+
asyncNodePlugin.hooks.onAsyncNode.tap("bench", () => {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const nodeNumber = lastCreatedNodeIndex + 1;
|
|
66
|
+
// Setup the resolve function to add a text asset and another async node.
|
|
67
|
+
resolveAsyncNode = () => {
|
|
68
|
+
lastCreatedNodeIndex = nodeNumber;
|
|
69
|
+
resolve([
|
|
70
|
+
{
|
|
71
|
+
asset: {
|
|
72
|
+
id: `bench-${nodeNumber}`,
|
|
73
|
+
type: "text",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: `another-async-${nodeNumber}`,
|
|
78
|
+
async: true,
|
|
79
|
+
flatten: true,
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// If this is not the last node to be added for this test, resolve it immediately, otherwise resolve the setup promise.
|
|
85
|
+
if (nodeNumber + 1 < nodeCount) {
|
|
86
|
+
resolveAsyncNode();
|
|
87
|
+
} else {
|
|
88
|
+
completeSetup();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const player = new Player({ plugins: [asyncNodePlugin] });
|
|
94
|
+
|
|
95
|
+
player.hooks.view.tap("bench", (fc) => {
|
|
96
|
+
fc.hooks.onUpdate.tap("bench", () => {
|
|
97
|
+
// Since the created index should go up to `nodeCount - 1` we wait for that to be true before transitioning player to its end state.
|
|
98
|
+
if (lastCreatedNodeIndex < nodeCount - 1) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const state = player.getState();
|
|
103
|
+
|
|
104
|
+
if (state.status !== "in-progress") {
|
|
105
|
+
throw new Error("benchmark failed");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
state.controllers.flow.transition("END");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
playerCompletePromise = player.start(asyncBenchFlow);
|
|
113
|
+
|
|
114
|
+
return setupPromise;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The bench setup function. This gets called once before all iterations of the test are run
|
|
118
|
+
const setup: BenchOptions["setup"] = (task) => {
|
|
119
|
+
// Add setup to the before each and wait on it to finish before starting a test run.
|
|
120
|
+
task.opts.beforeEach = async () => {
|
|
121
|
+
await setupPlayer();
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
bench(
|
|
126
|
+
`Resolve Async Node ${nodeCount} times`,
|
|
127
|
+
async () => {
|
|
128
|
+
// The test just resolves the last node and waits for player to complete.
|
|
129
|
+
resolveAsyncNode();
|
|
130
|
+
await playerCompletePromise;
|
|
131
|
+
},
|
|
132
|
+
{ iterations: 100, throws: true, setup },
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -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,93 @@ 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
|
+
});
|
|
1243
1441
|
});
|
|
1244
1442
|
|
|
1245
1443
|
describe("parser", () => {
|
package/src/index.ts
CHANGED
|
@@ -11,13 +11,23 @@ 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
|
+
};
|
|
30
|
+
|
|
21
31
|
export interface AsyncNodePluginOptions {
|
|
22
32
|
/** A set of plugins to load */
|
|
23
33
|
plugins?: AsyncNodeViewPlugin[];
|
|
@@ -34,12 +44,21 @@ export type AsyncHandler = (
|
|
|
34
44
|
callback?: (result: any) => void,
|
|
35
45
|
) => Promise<any>;
|
|
36
46
|
|
|
47
|
+
/** Hook declaration for the AsyncNodePlugin */
|
|
48
|
+
type AsyncNodeHooks = {
|
|
49
|
+
/** Async hook to get content for an async node */
|
|
50
|
+
onAsyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any>;
|
|
51
|
+
/** 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. */
|
|
52
|
+
onAsyncNodeError: SyncBailHook<[Error, Node.Async], any>;
|
|
53
|
+
};
|
|
54
|
+
|
|
37
55
|
/**
|
|
38
56
|
* Async node plugin used to resolve async nodes in the content
|
|
39
57
|
* If an async node is present, allow users to provide a replacement node to be rendered when ready
|
|
40
58
|
*/
|
|
41
59
|
export class AsyncNodePlugin implements PlayerPlugin {
|
|
42
60
|
private plugins: AsyncNodeViewPlugin[] | undefined;
|
|
61
|
+
private playerInstance: Player | undefined;
|
|
43
62
|
|
|
44
63
|
constructor(options: AsyncNodePluginOptions, asyncHandler?: AsyncHandler) {
|
|
45
64
|
if (options?.plugins) {
|
|
@@ -59,16 +78,20 @@ export class AsyncNodePlugin implements PlayerPlugin {
|
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
public readonly hooks = {
|
|
63
|
-
onAsyncNode: new AsyncParallelBailHook
|
|
64
|
-
|
|
65
|
-
any
|
|
66
|
-
>(),
|
|
81
|
+
public readonly hooks: AsyncNodeHooks = {
|
|
82
|
+
onAsyncNode: new AsyncParallelBailHook(),
|
|
83
|
+
onAsyncNodeError: new SyncBailHook(),
|
|
67
84
|
};
|
|
68
85
|
|
|
86
|
+
getPlayerInstance(): Player | undefined {
|
|
87
|
+
return this.playerInstance;
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
name = "AsyncNode";
|
|
70
91
|
|
|
71
|
-
apply(player: Player) {
|
|
92
|
+
apply(player: Player): void {
|
|
93
|
+
this.playerInstance = player;
|
|
94
|
+
|
|
72
95
|
player.hooks.viewController.tap(this.name, (viewController) => {
|
|
73
96
|
viewController.hooks.view.tap(this.name, (view) => {
|
|
74
97
|
this.plugins?.forEach((plugin) => {
|
|
@@ -80,40 +103,51 @@ export class AsyncNodePlugin implements PlayerPlugin {
|
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
|
|
83
|
-
public asyncNode
|
|
106
|
+
public asyncNode: AsyncParallelBailHook<
|
|
84
107
|
[Node.Async, (result: any) => void],
|
|
85
108
|
any
|
|
86
|
-
>();
|
|
109
|
+
> = new AsyncParallelBailHook();
|
|
87
110
|
private basePlugin: AsyncNodePlugin | undefined;
|
|
88
111
|
|
|
89
112
|
name = "AsyncNode";
|
|
90
113
|
|
|
91
|
-
private resolvedMapping = new Map<string, any>();
|
|
92
|
-
|
|
93
|
-
private currentView: ViewInstance | undefined;
|
|
94
|
-
|
|
95
114
|
/**
|
|
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.
|
|
115
|
+
* Parses the node from the result and triggers an asynchronous view update if necessary.
|
|
100
116
|
* @param node The asynchronous node that might be updated.
|
|
101
117
|
* @param result The result obtained from resolving the async node. This could be any data structure or value.
|
|
102
118
|
* @param options Options provided for node resolution, including a potential parseNode function to process the result.
|
|
103
119
|
* @param view The view instance where the node resides. This can be undefined if the view is not currently active.
|
|
104
120
|
*/
|
|
105
|
-
private
|
|
121
|
+
private parseNodeAndUpdate(
|
|
106
122
|
node: Node.Async,
|
|
123
|
+
context: AsyncPluginContext,
|
|
107
124
|
result: any,
|
|
108
125
|
options: Resolve.NodeResolveOptions,
|
|
109
|
-
view: ViewInstance | undefined,
|
|
110
126
|
) {
|
|
111
127
|
const parsedNode =
|
|
112
128
|
options.parseNode && result ? options.parseNode(result) : undefined;
|
|
113
129
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
this.handleAsyncUpdate(node, context, parsedNode);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Updates the node asynchronously based on the result provided.
|
|
135
|
+
* This method is responsible for handling the update logic of asynchronous nodes.
|
|
136
|
+
* It checks if the node needs to be updated based on the new result and updates the mapping accordingly.
|
|
137
|
+
* If an update is necessary, it triggers an asynchronous update on the view.
|
|
138
|
+
* @param node The asynchronous node that might be updated.
|
|
139
|
+
* @param newNode The new node to replace the async node.
|
|
140
|
+
* @param view The view instance where the node resides. This can be undefined if the view is not currently active.
|
|
141
|
+
*/
|
|
142
|
+
private handleAsyncUpdate(
|
|
143
|
+
node: Node.Async,
|
|
144
|
+
context: AsyncPluginContext,
|
|
145
|
+
newNode?: Node.Node | null,
|
|
146
|
+
) {
|
|
147
|
+
const { nodeResolveCache, view } = context;
|
|
148
|
+
if (nodeResolveCache.get(node.id) !== newNode) {
|
|
149
|
+
nodeResolveCache.set(node.id, newNode ? newNode : node);
|
|
150
|
+
view.updateAsync();
|
|
117
151
|
}
|
|
118
152
|
}
|
|
119
153
|
|
|
@@ -123,36 +157,59 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
|
|
|
123
157
|
* @param resolver The resolver instance to attach the hook to.
|
|
124
158
|
* @param view
|
|
125
159
|
*/
|
|
126
|
-
applyResolver(resolver: Resolver) {
|
|
160
|
+
applyResolver(resolver: Resolver, context: AsyncPluginContext): void {
|
|
127
161
|
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;
|
|
162
|
+
if (!this.isAsync(node)) {
|
|
163
|
+
return node;
|
|
136
164
|
}
|
|
137
165
|
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
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
|
-
});
|
|
149
|
-
|
|
150
|
-
return node;
|
|
166
|
+
const resolvedNode = context.nodeResolveCache.get(node.id);
|
|
167
|
+
if (resolvedNode !== undefined) {
|
|
168
|
+
return resolvedNode;
|
|
151
169
|
}
|
|
152
|
-
|
|
170
|
+
|
|
171
|
+
queueMicrotask(() => this.runAsyncNode(node, context, options));
|
|
172
|
+
|
|
173
|
+
return node;
|
|
153
174
|
});
|
|
154
175
|
}
|
|
155
176
|
|
|
177
|
+
private async runAsyncNode(
|
|
178
|
+
node: Node.Async,
|
|
179
|
+
context: AsyncPluginContext,
|
|
180
|
+
options: Resolve.NodeResolveOptions,
|
|
181
|
+
) {
|
|
182
|
+
try {
|
|
183
|
+
const result = await this.basePlugin?.hooks.onAsyncNode.call(
|
|
184
|
+
node,
|
|
185
|
+
(result) => {
|
|
186
|
+
this.parseNodeAndUpdate(node, context, result, options);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
this.parseNodeAndUpdate(node, context, result, options);
|
|
190
|
+
} catch (e: unknown) {
|
|
191
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
192
|
+
const result = this.basePlugin?.hooks.onAsyncNodeError.call(error, node);
|
|
193
|
+
|
|
194
|
+
if (result === undefined) {
|
|
195
|
+
const playerState = this.basePlugin?.getPlayerInstance()?.getState();
|
|
196
|
+
|
|
197
|
+
if (playerState?.status === "in-progress") {
|
|
198
|
+
playerState.fail(error);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
options.logger?.error(
|
|
205
|
+
"Async node handling failed and resolved with a fallback. Error:",
|
|
206
|
+
error,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
this.parseNodeAndUpdate(node, context, result, options);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
156
213
|
private isAsync(node: Node.Node | null): node is Node.Async {
|
|
157
214
|
return node?.type === NodeType.Async;
|
|
158
215
|
}
|
|
@@ -161,7 +218,7 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
|
|
|
161
218
|
return obj && Object.prototype.hasOwnProperty.call(obj, "async");
|
|
162
219
|
}
|
|
163
220
|
|
|
164
|
-
applyParser(parser: Parser) {
|
|
221
|
+
applyParser(parser: Parser): void {
|
|
165
222
|
parser.hooks.parseNode.tap(
|
|
166
223
|
this.name,
|
|
167
224
|
(
|
|
@@ -209,9 +266,15 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
|
|
|
209
266
|
}
|
|
210
267
|
|
|
211
268
|
apply(view: ViewInstance): void {
|
|
212
|
-
|
|
269
|
+
const context: AsyncPluginContext = {
|
|
270
|
+
nodeResolveCache: new Map<string, any>(),
|
|
271
|
+
view,
|
|
272
|
+
};
|
|
273
|
+
|
|
213
274
|
view.hooks.parser.tap("async", this.applyParser.bind(this));
|
|
214
|
-
view.hooks.resolver.tap("async",
|
|
275
|
+
view.hooks.resolver.tap("async", (resolver) => {
|
|
276
|
+
this.applyResolver(resolver, context);
|
|
277
|
+
});
|
|
215
278
|
}
|
|
216
279
|
|
|
217
280
|
applyPlugin(asyncNodePlugin: AsyncNodePlugin): void {
|