@player-ui/async-node-plugin 0.15.3-next.3 → 0.15.4--canary.881.37421

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.
@@ -8,6 +8,11 @@ import {
8
8
  BeforeTransformFunction,
9
9
  Flow,
10
10
  NodeType,
11
+ Logger,
12
+ ErrorTypes,
13
+ ErrorSeverity,
14
+ PlayerErrorMetadata,
15
+ ErrorMetadata,
11
16
  } from "@player-ui/player";
12
17
  import { Player, Parser } from "@player-ui/player";
13
18
  import { waitFor } from "@testing-library/react";
@@ -18,6 +23,19 @@ import {
18
23
  } from "../index";
19
24
  import { CheckPathPlugin } from "@player-ui/check-path-plugin";
20
25
  import { Registry } from "@player-ui/partial-match-registry";
26
+ import { ExpressionPlugin } from "@player-ui/expression-plugin";
27
+ import { AsyncNodeError } from "../AsyncNodeError";
28
+
29
+ class ErrorWithProps extends Error implements PlayerErrorMetadata {
30
+ constructor(
31
+ message: string,
32
+ public type: string,
33
+ public severity?: ErrorSeverity,
34
+ public metadata?: ErrorMetadata,
35
+ ) {
36
+ super(message);
37
+ }
38
+ }
21
39
 
22
40
  const transform: BeforeTransformFunction = createAsyncTransform({
23
41
  transformAssetType: "chat-message",
@@ -79,7 +97,35 @@ const asyncAssetFrf: Flow = {
79
97
  VIEW_1: {
80
98
  state_type: "VIEW",
81
99
  ref: "my-view",
82
- transitions: {},
100
+ transitions: {
101
+ "*": "END",
102
+ },
103
+ },
104
+ END: {
105
+ state_type: "END",
106
+ outcome: "done",
107
+ },
108
+ },
109
+ },
110
+ };
111
+
112
+ const nonViewErrorFlow: Flow = {
113
+ id: "test-flow",
114
+ views: [],
115
+ navigation: {
116
+ BEGIN: "FLOW_1",
117
+ FLOW_1: {
118
+ startState: "ACTION_1",
119
+ ACTION_1: {
120
+ state_type: "ACTION",
121
+ exp: ["captureError()"],
122
+ transitions: {
123
+ "*": "END",
124
+ },
125
+ },
126
+ END: {
127
+ state_type: "END",
128
+ outcome: "done",
83
129
  },
84
130
  },
85
131
  },
@@ -1075,7 +1121,7 @@ describe("view", () => {
1075
1121
 
1076
1122
  await waitFor(() => {
1077
1123
  expect(onAsyncNodeErrorCallback).toHaveBeenCalledWith(
1078
- new Error("Promise Rejected"),
1124
+ expect.objectContaining({ cause: new Error("Promise Rejected") }),
1079
1125
  expect.anything(),
1080
1126
  );
1081
1127
 
@@ -1105,14 +1151,241 @@ describe("view", () => {
1105
1151
 
1106
1152
  await waitFor(() => {
1107
1153
  expect(onAsyncNodeErrorCallback).toHaveBeenCalledWith(
1108
- new Error("Promise Rejected"),
1154
+ expect.objectContaining({ cause: new Error("Promise Rejected") }),
1109
1155
  expect.anything(),
1110
1156
  );
1111
1157
 
1112
1158
  const playerState = player.getState();
1113
1159
  expect(playerState.status).toBe("error");
1114
1160
  const errorState = playerState as ErrorState;
1115
- expect(errorState.error.message).toBe("Promise Rejected");
1161
+ expect(errorState.error.message).toBe(
1162
+ "An error occured during async node resolution. See cause for details.",
1163
+ );
1164
+ expect(errorState.error).toBeInstanceOf(AsyncNodeError);
1165
+ expect((errorState.error as AsyncNodeError).cause?.message).toBe(
1166
+ "Promise Rejected",
1167
+ );
1168
+ });
1169
+ });
1170
+
1171
+ test("should log and absorb errors occuring outside of an in-progress state", async () => {
1172
+ const vitestLogger: Logger = {
1173
+ debug: vi.fn(),
1174
+ error: vi.fn(),
1175
+ info: vi.fn(),
1176
+ trace: vi.fn(),
1177
+ warn: vi.fn(),
1178
+ };
1179
+ const plugin = new AsyncNodePlugin({
1180
+ plugins: [new AsyncNodePluginPlugin()],
1181
+ });
1182
+
1183
+ let throwAsyncError: ((err: Error) => void) | undefined;
1184
+
1185
+ plugin.hooks.onAsyncNode.tap("test", async () => {
1186
+ return new Promise((_, rej) => {
1187
+ throwAsyncError = rej;
1188
+ });
1189
+ });
1190
+
1191
+ const plugins = [plugin, new TestAsyncPlugin()];
1192
+
1193
+ const player = new Player({
1194
+ plugins: plugins,
1195
+ logger: vitestLogger,
1196
+ });
1197
+
1198
+ player.start(asyncAssetFrf);
1199
+
1200
+ await vi.waitFor(() => {
1201
+ expect(throwAsyncError).toBeDefined();
1202
+ });
1203
+
1204
+ (player.getState() as InProgressState).controllers.flow.transition(
1205
+ "done",
1206
+ );
1207
+ await vi.waitFor(() => {
1208
+ const playerState = player.getState();
1209
+ expect(playerState.status).toBe("completed");
1210
+ });
1211
+
1212
+ throwAsyncError!(new Error("Test Error"));
1213
+
1214
+ await vi.waitFor(() => {
1215
+ const state = player.getState();
1216
+ // should not leave completed state
1217
+ expect(state.status).toBe("completed");
1218
+
1219
+ expect(vitestLogger.warn).toHaveBeenCalledWith(
1220
+ expect.any(String), // Message doesn't matter, just check that the logged error is correct
1221
+ new Error("Test Error"),
1222
+ );
1223
+ });
1224
+ });
1225
+
1226
+ test("should do nothing with errors when not in a view state", async () => {
1227
+ const plugin = new AsyncNodePlugin({
1228
+ plugins: [new AsyncNodePluginPlugin()],
1229
+ });
1230
+ // Call capture error in an action state to make sure asyncnodeplugin doesn't try to handle this
1231
+ const expPlugin = new ExpressionPlugin(
1232
+ new Map([
1233
+ [
1234
+ "captureError",
1235
+ () => {
1236
+ (
1237
+ player.getState() as InProgressState
1238
+ ).controllers.error.captureError(
1239
+ new ErrorWithProps(
1240
+ "Test Error",
1241
+ ErrorTypes.RENDER,
1242
+ ErrorSeverity.ERROR,
1243
+ { assetId: "asset" },
1244
+ ),
1245
+ );
1246
+ },
1247
+ ],
1248
+ ]),
1249
+ );
1250
+ const plugins = [plugin, expPlugin, new TestAsyncPlugin()];
1251
+
1252
+ const player = new Player({
1253
+ plugins: plugins,
1254
+ });
1255
+
1256
+ player.start(nonViewErrorFlow).catch(() => {});
1257
+
1258
+ await vi.waitFor(() => {
1259
+ const state = player.getState();
1260
+ expect(state.status).toBe("error");
1261
+ expect(onAsyncNodeErrorCallback).not.toHaveBeenCalled();
1262
+ });
1263
+ });
1264
+
1265
+ test("should fail to handle errors if the plugin is setup incorrectly", async () => {
1266
+ const vitestLogger: Logger = {
1267
+ debug: vi.fn(),
1268
+ error: vi.fn(),
1269
+ info: vi.fn(),
1270
+ trace: vi.fn(),
1271
+ warn: vi.fn(),
1272
+ };
1273
+ const nodePluginPlugin = new AsyncNodePluginPlugin();
1274
+
1275
+ // Apply AsyncNodePluginPlugin in isolation to observe failures
1276
+ const plugin: PlayerPlugin = {
1277
+ name: "TestPlayerPlugin",
1278
+ apply: (player: Player) => {
1279
+ nodePluginPlugin.applyPlayer(player);
1280
+ player.hooks.view.tap("test", (view) => {
1281
+ nodePluginPlugin.apply(view);
1282
+ });
1283
+ },
1284
+ };
1285
+ const plugins = [plugin, new TestAsyncPlugin()];
1286
+
1287
+ const player = new Player({
1288
+ plugins: plugins,
1289
+ logger: vitestLogger,
1290
+ });
1291
+ player.start(asyncAssetFrf).catch(() => {});
1292
+
1293
+ await vi.waitFor(() => {
1294
+ const state = player.getState();
1295
+ expect(state.status).toBe("in-progress");
1296
+ });
1297
+
1298
+ (player.getState() as InProgressState).controllers.error.captureError(
1299
+ new ErrorWithProps("Test Error", ErrorTypes.VIEW, ErrorSeverity.ERROR, {
1300
+ node: {
1301
+ type: NodeType.Async,
1302
+ value: {},
1303
+ },
1304
+ }),
1305
+ );
1306
+
1307
+ await vi.waitFor(() => {
1308
+ const state = player.getState();
1309
+ expect(state.status).toBe("error");
1310
+ expect(onAsyncNodeErrorCallback).not.toHaveBeenCalled();
1311
+ expect(vitestLogger.warn).toHaveBeenCalledWith(
1312
+ "[AsyncNodePlugin]: No plugin detected. Error handling will fail",
1313
+ );
1314
+ });
1315
+ });
1316
+
1317
+ test("should call onAsyncNodeError hook for any async node involved in generating the current one", async () => {
1318
+ const plugin = new AsyncNodePlugin({
1319
+ plugins: [new AsyncNodePluginPlugin()],
1320
+ });
1321
+
1322
+ const errorHandler = vi.fn((err: Error, node: Node.Async) => {
1323
+ if (node.id === "async-chat-id-2") {
1324
+ return {
1325
+ asset: {
1326
+ type: "text",
1327
+ value: "text",
1328
+ id: "FIXED",
1329
+ },
1330
+ };
1331
+ }
1332
+
1333
+ return undefined;
1334
+ });
1335
+
1336
+ plugin.hooks.onAsyncNodeError.tap("test", errorHandler);
1337
+
1338
+ let id = 0;
1339
+ plugin.hooks.onAsyncNode.tap("test", () => {
1340
+ const messageId = id++;
1341
+ if (messageId > 5) {
1342
+ throw new Error("Test Error");
1343
+ }
1344
+
1345
+ return Promise.resolve({
1346
+ asset: {
1347
+ type: "chat-message",
1348
+ id: `chat-id-${messageId}`,
1349
+ value: {
1350
+ id: `chat-id-text-${messageId}`,
1351
+ type: "text",
1352
+ value: `Test Message ${messageId}`,
1353
+ },
1354
+ },
1355
+ });
1356
+ });
1357
+
1358
+ const player = new Player({
1359
+ plugins: [plugin, new TestAsyncPlugin()],
1360
+ });
1361
+ player.start(asyncAssetFrf).catch(() => {});
1362
+
1363
+ await vi.waitFor(() => {
1364
+ expect(id).toBeGreaterThan(5);
1365
+ expect(errorHandler).toHaveBeenCalledWith(
1366
+ expect.anything(),
1367
+ expect.objectContaining({
1368
+ id: "async-chat-id-5",
1369
+ }),
1370
+ );
1371
+ expect(errorHandler).toHaveBeenCalledWith(
1372
+ expect.anything(),
1373
+ expect.objectContaining({
1374
+ id: "async-chat-id-4",
1375
+ }),
1376
+ );
1377
+ expect(errorHandler).toHaveBeenCalledWith(
1378
+ expect.anything(),
1379
+ expect.objectContaining({
1380
+ id: "async-chat-id-3",
1381
+ }),
1382
+ );
1383
+ expect(errorHandler).toHaveBeenCalledWith(
1384
+ expect.anything(),
1385
+ expect.objectContaining({
1386
+ id: "async-chat-id-2",
1387
+ }),
1388
+ );
1116
1389
  });
1117
1390
  });
1118
1391
  });
package/src/index.ts CHANGED
@@ -10,33 +10,17 @@ import type {
10
10
  ViewPlugin,
11
11
  Resolver,
12
12
  Resolve,
13
- ViewController,
14
13
  } from "@player-ui/player";
15
14
  import { AsyncSeriesBailHook, SyncBailHook } from "tapable-ts";
16
15
  import queueMicrotask from "queue-microtask";
16
+ import { AsyncNodeError } from "./AsyncNodeError";
17
+ import { AsyncNodeInfo, AsyncPluginContext } from "./internal-types";
18
+ import { getNodeFromError } from "./utils";
17
19
 
18
20
  export * from "./types";
19
21
  export * from "./transform";
20
22
  export * from "./createAsyncTransform";
21
23
 
22
- /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
23
- * 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.
24
- */
25
- type AsyncPluginContext = {
26
- /** Map of async node id to resolved content */
27
- nodeResolveCache: Map<string, Node.Node>;
28
- /** The view instance this context is attached to. */
29
- view: ViewInstance;
30
- /** The view controller this context is attached to. */
31
- viewController: ViewController;
32
- /** Map of async node id to promises being used to resolve them */
33
- inProgressNodes: Set<string>;
34
- /** Map of async node ids to the original node they represent.
35
- * In some cases, async nodes are transformed into from other node types so the original reference is needed in order to trigger an update on the view when the async node changes.
36
- */
37
- originalNodeCache: Map<string, Set<Node.Node>>;
38
- };
39
-
40
24
  export interface AsyncNodePluginOptions {
41
25
  /** A set of plugins to load */
42
26
  plugins?: AsyncNodeViewPlugin[];
@@ -142,10 +126,10 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
142
126
  node: Node.Async,
143
127
  context: AsyncPluginContext,
144
128
  result: any,
145
- options: Resolve.NodeResolveOptions,
129
+ parseFunction?: (content: any) => Node.Node | null,
146
130
  ) {
147
131
  let parsedNode =
148
- options.parseNode && result ? options.parseNode(result) : undefined;
132
+ parseFunction && result ? parseFunction(result) : undefined;
149
133
 
150
134
  if (parsedNode && node.onValueReceived) {
151
135
  parsedNode = node.onValueReceived(parsedNode);
@@ -168,24 +152,43 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
168
152
  context: AsyncPluginContext,
169
153
  newNode?: Node.Node | null,
170
154
  ) {
171
- const { nodeResolveCache, viewController, originalNodeCache } = context;
172
- if (nodeResolveCache.get(node.id) !== newNode) {
173
- nodeResolveCache.set(node.id, newNode ? newNode : node);
174
- const originalNode = originalNodeCache.get(node.id) ?? new Set([node]);
175
- viewController.updateViewAST(originalNode);
155
+ const { asyncNodeCache: asyncNodeInfo, viewController } = context;
156
+ const entry = asyncNodeInfo.get(node.id);
157
+ if (!entry) {
158
+ throw new Error("Failed to update async content. Cache entry not found");
159
+ }
160
+ if (entry.resolvedContent !== newNode) {
161
+ entry.resolvedContent = newNode ? newNode : entry.asyncNode;
162
+ viewController.updateViewAST(entry.updateNodes);
176
163
  }
177
164
  }
178
165
 
179
166
  private hasValidMapping(
180
- node: Node.Async,
181
- context: AsyncPluginContext,
182
- ): boolean {
183
- const { nodeResolveCache } = context;
167
+ cacheEntry: AsyncNodeInfo,
168
+ ): cacheEntry is Required<AsyncNodeInfo> {
184
169
  return (
185
- nodeResolveCache.has(node.id) && nodeResolveCache.get(node.id) !== node
170
+ cacheEntry.resolvedContent !== undefined &&
171
+ cacheEntry.resolvedContent !== cacheEntry.asyncNode
186
172
  );
187
173
  }
188
174
 
175
+ private getOrCreateAsyncNodeCacheEntry(
176
+ node: Node.Async,
177
+ context: AsyncPluginContext,
178
+ ): AsyncNodeInfo {
179
+ const { asyncNodeCache: asyncNodeInfo } = context;
180
+ let entry = asyncNodeInfo.get(node.id);
181
+ if (!entry) {
182
+ entry = {
183
+ asyncNode: node,
184
+ updateNodes: new Set(),
185
+ };
186
+ asyncNodeInfo.set(node.id, entry);
187
+ }
188
+
189
+ return entry;
190
+ }
191
+
189
192
  /**
190
193
  * Handles the asynchronous API integration for resolving nodes.
191
194
  * This method sets up a hook on the resolver's `beforeResolve` event to process async nodes.
@@ -193,17 +196,32 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
193
196
  * @param view
194
197
  */
195
198
  applyResolver(resolver: Resolver, context: AsyncPluginContext): void {
199
+ const { assetIdCache } = context;
200
+ resolver.hooks.afterNodeUpdate.tap(this.name, (original, _, update) => {
201
+ if (
202
+ update.node.type !== NodeType.Asset &&
203
+ update.node.type !== NodeType.View
204
+ ) {
205
+ return;
206
+ }
207
+
208
+ assetIdCache.set(update.value.id, original);
209
+ });
210
+
196
211
  resolver.hooks.beforeResolve.tap(this.name, (node, options) => {
197
212
  if (!this.isAsync(node)) {
198
213
  return node === null ? node : this.resolveAsyncChildren(node, context);
199
214
  }
215
+
216
+ const entry = this.getOrCreateAsyncNodeCacheEntry(node, context);
217
+
200
218
  if (options.node) {
201
- context.originalNodeCache.set(node.id, new Set([options.node]));
219
+ entry.updateNodes = new Set([options.node]);
220
+ context.generatedByMap.set(options.node, node.id);
202
221
  }
203
222
 
204
- const resolvedNode = context.nodeResolveCache.get(node.id);
205
- if (resolvedNode !== undefined) {
206
- return this.resolveAsyncChildren(resolvedNode, context);
223
+ if (entry.resolvedContent !== undefined) {
224
+ return this.resolveAsyncChildren(entry.resolvedContent, context);
207
225
  }
208
226
 
209
227
  if (context.inProgressNodes.has(node.id)) {
@@ -236,20 +254,24 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
236
254
  let index = 0;
237
255
  while (index < node.values.length) {
238
256
  const childNode = node.values[index];
239
- if (
240
- childNode?.type !== NodeType.Async ||
241
- !this.hasValidMapping(childNode, context)
242
- ) {
257
+ if (childNode?.type !== NodeType.Async) {
243
258
  index++;
244
259
  continue;
245
260
  }
261
+ const entry = this.getOrCreateAsyncNodeCacheEntry(childNode, context);
246
262
 
247
- const mappedNode = context.nodeResolveCache.get(childNode.id)!;
263
+ if (!this.hasValidMapping(entry)) {
264
+ index++;
265
+ continue;
266
+ }
267
+
268
+ const mappedNode = entry.resolvedContent;
248
269
  const nodeSet = new Set<Node.Node>();
249
270
  if (mappedNode.type === NodeType.MultiNode && childNode.flatten) {
250
271
  mappedNode.values.forEach((v: Node.Node) => {
251
272
  v.parent = node;
252
273
  nodeSet.add(v);
274
+ context.originalParentMap.set(v, childNode);
253
275
  });
254
276
  node.values = [
255
277
  ...node.values.slice(0, index),
@@ -261,17 +283,23 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
261
283
  mappedNode.parent = node;
262
284
  nodeSet.add(mappedNode);
263
285
  }
264
- context.originalNodeCache.set(childNode.id, nodeSet);
286
+ entry.updateNodes = nodeSet;
287
+ for (const n of nodeSet) {
288
+ context.generatedByMap.set(n, childNode.id);
289
+ }
265
290
  }
266
291
  } else if ("children" in node) {
267
292
  node.children?.forEach((c) => {
268
293
  // Similar to above, using a while loop lets us handle when async nodes produce more async nodes.
269
- while (
270
- c.value.type === NodeType.Async &&
271
- this.hasValidMapping(c.value, context)
272
- ) {
273
- const mappedNode = context.nodeResolveCache.get(c.value.id)!;
274
- context.originalNodeCache.set(c.value.id, new Set([mappedNode]));
294
+ while (c.value.type === NodeType.Async) {
295
+ const entry = this.getOrCreateAsyncNodeCacheEntry(c.value, context);
296
+ if (!this.hasValidMapping(entry)) {
297
+ break;
298
+ }
299
+
300
+ const mappedNode = entry.resolvedContent;
301
+ entry.updateNodes = new Set([mappedNode]);
302
+ context.generatedByMap.set(mappedNode, c.value.id);
275
303
  c.value = mappedNode;
276
304
  c.value.parent = node;
277
305
  }
@@ -290,35 +318,31 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
290
318
  const result = await this.basePlugin?.hooks.onAsyncNode.call(
291
319
  node,
292
320
  (result) => {
293
- this.parseNodeAndUpdate(node, context, result, options);
321
+ this.parseNodeAndUpdate(node, context, result, options.parseNode);
294
322
  },
295
323
  );
296
324
 
297
325
  // Stop tracking before the next update is triggered
298
326
  context.inProgressNodes.delete(node.id);
299
- this.parseNodeAndUpdate(node, context, result, options);
327
+ this.parseNodeAndUpdate(node, context, result, options.parseNode);
300
328
  } catch (e: unknown) {
301
- const error = e instanceof Error ? e : new Error(String(e));
302
- const result = this.basePlugin?.hooks.onAsyncNodeError.call(error, node);
303
-
304
- if (result === undefined) {
305
- const playerState = this.basePlugin?.getPlayerInstance()?.getState();
306
-
307
- if (playerState?.status === "in-progress") {
308
- playerState.fail(error);
309
- }
310
-
329
+ const cause = e instanceof Error ? e : new Error(String(e));
330
+ const playerState = this.basePlugin?.getPlayerInstance()?.getState();
331
+
332
+ if (playerState?.status !== "in-progress") {
333
+ options.logger?.warn(
334
+ "[AsyncNodePlugin]: An error occured during async node resolution, but the player instance is no londer running. Exception: ",
335
+ cause,
336
+ );
311
337
  return;
312
338
  }
313
339
 
314
- options.logger?.error(
315
- "Async node handling failed and resolved with a fallback. Error:",
316
- error,
340
+ const error = new AsyncNodeError(
341
+ node,
342
+ "An error occured during async node resolution. See cause for details.",
343
+ cause,
317
344
  );
318
-
319
- // Stop tracking before the next update is triggered
320
- context.inProgressNodes.delete(node.id);
321
- this.parseNodeAndUpdate(node, context, result, options);
345
+ playerState.controllers.error.captureError(error);
322
346
  }
323
347
  }
324
348
 
@@ -385,15 +409,112 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
385
409
  }
386
410
 
387
411
  applyPlayer(player: Player): void {
412
+ // TODO: Need a better mechanism for storing the current context.
413
+ let currentContext: AsyncPluginContext | undefined = undefined;
414
+ let parser: Parser | undefined = undefined;
415
+
416
+ player.hooks.errorController.tap("async", (errorController) => {
417
+ errorController.hooks.onError.tap("async", (playerError) => {
418
+ if (currentContext === undefined) {
419
+ return undefined;
420
+ }
421
+
422
+ /** Try to handle the error using the onAsyncNodeError hook. Returns true if new content is provided. */
423
+ const tryHandleError = (asyncNode: Node.Async): boolean => {
424
+ if (this.basePlugin === undefined) {
425
+ player.logger.warn(
426
+ `[AsyncNodePlugin]: No plugin detected. Error handling will fail`,
427
+ );
428
+ }
429
+
430
+ let result: any = undefined;
431
+ result = this.basePlugin?.hooks.onAsyncNodeError.call(
432
+ playerError,
433
+ asyncNode,
434
+ );
435
+
436
+ if (result === undefined) {
437
+ return false;
438
+ }
439
+
440
+ player.logger?.warn(
441
+ "[AsyncNodePlugin]: Async node handling failed and resolved with a fallback. Cause:",
442
+ playerError.message,
443
+ );
444
+
445
+ // Stop tracking before the next update is triggered
446
+ currentContext!.inProgressNodes.delete(asyncNode.id);
447
+ this.parseNodeAndUpdate(
448
+ asyncNode,
449
+ currentContext!,
450
+ result,
451
+ parser?.parseObject.bind(parser),
452
+ );
453
+
454
+ return true;
455
+ };
456
+
457
+ const getNextNode = (node: Node.Node): Node.Node | undefined => {
458
+ const parent =
459
+ currentContext?.originalParentMap.get(node) ?? node.parent;
460
+
461
+ if (!parent) {
462
+ return undefined;
463
+ }
464
+
465
+ // asyncNodeCache has current asyncNode reference more up to date with what's happening in the resolver. Sometimes AsyncNodeError has old references so this helps us move up the tree more accurately
466
+ return this.isAsync(parent)
467
+ ? currentContext?.asyncNodeCache.get(parent.id)?.asyncNode
468
+ : parent;
469
+ };
470
+
471
+ let node = getNodeFromError(playerError, currentContext);
472
+ // If the node is an async node try, to handle errors with it first.
473
+ if (node?.type === NodeType.Async && tryHandleError(node)) {
474
+ return true;
475
+ }
476
+
477
+ // Loop through the nodes to see if something is generated by something else. Continue until the error is handled or there are no more nodes to check
478
+ while (node !== undefined) {
479
+ const generatedBy = currentContext.generatedByMap.get(node);
480
+ if (generatedBy) {
481
+ const entry = currentContext.asyncNodeCache.get(generatedBy);
482
+
483
+ if (!entry) {
484
+ node = getNextNode(node);
485
+ continue;
486
+ }
487
+
488
+ const { asyncNode } = entry;
489
+
490
+ // Don't return false when the error isn't handled to allow for cases where one async is generated by another. Give different nodes a chance to try to recover from the error.
491
+ if (tryHandleError(asyncNode)) {
492
+ return true;
493
+ }
494
+ }
495
+
496
+ node = getNextNode(node);
497
+ }
498
+
499
+ return undefined;
500
+ });
501
+ });
502
+
388
503
  player.hooks.viewController.tap("async", (viewController) => {
389
504
  viewController.hooks.view.tap("async", (view) => {
505
+ view.hooks.parser.tap(this.name, (p) => {
506
+ parser = p;
507
+ });
390
508
  const context: AsyncPluginContext = {
391
- nodeResolveCache: new Map(),
392
509
  inProgressNodes: new Set(),
393
510
  view,
394
511
  viewController,
395
- originalNodeCache: new Map(),
512
+ generatedByMap: new Map(),
513
+ assetIdCache: new Map(),
514
+ asyncNodeCache: new Map(),
515
+ originalParentMap: new Map(),
396
516
  };
517
+ currentContext = context;
397
518
 
398
519
  view.hooks.resolver.tap("async", (resolver) => {
399
520
  this.applyResolver(resolver, context);