@player-ui/async-node-plugin 1.1.1--canary.934.41671 → 1.1.1--canary.937.41794

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/src/index.ts CHANGED
@@ -10,17 +10,33 @@ import type {
10
10
  ViewPlugin,
11
11
  Resolver,
12
12
  Resolve,
13
+ ViewController,
13
14
  } from "@player-ui/player";
14
15
  import { AsyncSeriesBailHook, SyncBailHook } from "tapable-ts";
15
16
  import queueMicrotask from "queue-microtask";
16
- import { AsyncNodeError } from "./AsyncNodeError";
17
- import { AsyncNodeInfo, AsyncPluginContext } from "./internal-types";
18
- import { getNodeFromError } from "./utils";
19
17
 
20
18
  export * from "./types";
21
19
  export * from "./transform";
22
20
  export * from "./createAsyncTransform";
23
21
 
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
+
24
40
  export interface AsyncNodePluginOptions {
25
41
  /** A set of plugins to load */
26
42
  plugins?: AsyncNodeViewPlugin[];
@@ -126,10 +142,10 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
126
142
  node: Node.Async,
127
143
  context: AsyncPluginContext,
128
144
  result: any,
129
- parseFunction?: (content: any) => Node.Node | null,
145
+ options: Resolve.NodeResolveOptions,
130
146
  ) {
131
147
  let parsedNode =
132
- parseFunction && result ? parseFunction(result) : undefined;
148
+ options.parseNode && result ? options.parseNode(result) : undefined;
133
149
 
134
150
  if (parsedNode && node.onValueReceived) {
135
151
  parsedNode = node.onValueReceived(parsedNode);
@@ -152,41 +168,22 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
152
168
  context: AsyncPluginContext,
153
169
  newNode?: Node.Node | null,
154
170
  ) {
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);
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);
163
176
  }
164
177
  }
165
178
 
166
179
  private hasValidMapping(
167
- cacheEntry: AsyncNodeInfo,
168
- ): cacheEntry is Required<AsyncNodeInfo> {
169
- return (
170
- cacheEntry.resolvedContent !== undefined &&
171
- cacheEntry.resolvedContent !== cacheEntry.asyncNode
172
- );
173
- }
174
-
175
- private getOrCreateAsyncNodeCacheEntry(
176
180
  node: Node.Async,
177
181
  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;
182
+ ): boolean {
183
+ const { nodeResolveCache } = context;
184
+ return (
185
+ nodeResolveCache.has(node.id) && nodeResolveCache.get(node.id) !== node
186
+ );
190
187
  }
191
188
 
192
189
  /**
@@ -196,32 +193,17 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
196
193
  * @param view
197
194
  */
198
195
  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
-
211
196
  resolver.hooks.beforeResolve.tap(this.name, (node, options) => {
212
197
  if (!this.isAsync(node)) {
213
198
  return node === null ? node : this.resolveAsyncChildren(node, context);
214
199
  }
215
-
216
- const entry = this.getOrCreateAsyncNodeCacheEntry(node, context);
217
-
218
200
  if (options.node) {
219
- entry.updateNodes = new Set([options.node]);
220
- context.generatedByMap.set(options.node, node.id);
201
+ context.originalNodeCache.set(node.id, new Set([options.node]));
221
202
  }
222
203
 
223
- if (entry.resolvedContent !== undefined) {
224
- return this.resolveAsyncChildren(entry.resolvedContent, context);
204
+ const resolvedNode = context.nodeResolveCache.get(node.id);
205
+ if (resolvedNode !== undefined) {
206
+ return this.resolveAsyncChildren(resolvedNode, context);
225
207
  }
226
208
 
227
209
  if (context.inProgressNodes.has(node.id)) {
@@ -254,24 +236,20 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
254
236
  let index = 0;
255
237
  while (index < node.values.length) {
256
238
  const childNode = node.values[index];
257
- if (childNode?.type !== NodeType.Async) {
239
+ if (
240
+ childNode?.type !== NodeType.Async ||
241
+ !this.hasValidMapping(childNode, context)
242
+ ) {
258
243
  index++;
259
244
  continue;
260
245
  }
261
- const entry = this.getOrCreateAsyncNodeCacheEntry(childNode, context);
262
246
 
263
- if (!this.hasValidMapping(entry)) {
264
- index++;
265
- continue;
266
- }
267
-
268
- const mappedNode = entry.resolvedContent;
247
+ const mappedNode = context.nodeResolveCache.get(childNode.id)!;
269
248
  const nodeSet = new Set<Node.Node>();
270
249
  if (mappedNode.type === NodeType.MultiNode && childNode.flatten) {
271
250
  mappedNode.values.forEach((v: Node.Node) => {
272
251
  v.parent = node;
273
252
  nodeSet.add(v);
274
- context.originalParentMap.set(v, childNode);
275
253
  });
276
254
  node.values = [
277
255
  ...node.values.slice(0, index),
@@ -283,23 +261,17 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
283
261
  mappedNode.parent = node;
284
262
  nodeSet.add(mappedNode);
285
263
  }
286
- entry.updateNodes = nodeSet;
287
- for (const n of nodeSet) {
288
- context.generatedByMap.set(n, childNode.id);
289
- }
264
+ context.originalNodeCache.set(childNode.id, nodeSet);
290
265
  }
291
266
  } else if ("children" in node) {
292
267
  node.children?.forEach((c) => {
293
268
  // Similar to above, using a while loop lets us handle when async nodes produce more async nodes.
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);
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]));
303
275
  c.value = mappedNode;
304
276
  c.value.parent = node;
305
277
  }
@@ -315,51 +287,38 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
315
287
  options: Resolve.NodeResolveOptions,
316
288
  ) {
317
289
  try {
318
- // Track whether the callback pushed content while the hook was pending;
319
- // if so it wins over the bail return (avoids the last-writer-wins race
320
- // that intermittently dropped streamed rows).
321
- let deliveredViaCallback = false;
322
-
323
290
  const result = await this.basePlugin?.hooks.onAsyncNode.call(
324
291
  node,
325
- (streamedResult) => {
326
- deliveredViaCallback = true;
327
- this.parseNodeAndUpdate(
328
- node,
329
- context,
330
- streamedResult,
331
- options.parseNode,
332
- );
292
+ (result) => {
293
+ this.parseNodeAndUpdate(node, context, result, options);
333
294
  },
334
295
  );
335
296
 
336
297
  // Stop tracking before the next update is triggered
337
298
  context.inProgressNodes.delete(node.id);
338
-
339
- // Once the callback has delivered content it is the source of truth, so a
340
- // stale/empty bail return must not clobber it. Skip unless no callback ran
341
- // (!deliveredViaCallback) and the return carries content (result != null).
342
- if (!deliveredViaCallback && result != null) {
343
- this.parseNodeAndUpdate(node, context, result, options.parseNode);
344
- }
299
+ this.parseNodeAndUpdate(node, context, result, options);
345
300
  } catch (e: unknown) {
346
- const cause = e instanceof Error ? e : new Error(String(e));
347
- const playerState = this.basePlugin?.getPlayerInstance()?.getState();
348
-
349
- if (playerState?.status !== "in-progress") {
350
- options.logger?.warn(
351
- "[AsyncNodePlugin]: An error occured during async node resolution, but the player instance is no londer running. Exception: ",
352
- cause,
353
- );
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
+
354
311
  return;
355
312
  }
356
313
 
357
- const error = new AsyncNodeError(
358
- node,
359
- "An error occured during async node resolution. See cause for details.",
360
- cause,
314
+ options.logger?.error(
315
+ "Async node handling failed and resolved with a fallback. Error:",
316
+ error,
361
317
  );
362
- playerState.controllers.error.captureError(error);
318
+
319
+ // Stop tracking before the next update is triggered
320
+ context.inProgressNodes.delete(node.id);
321
+ this.parseNodeAndUpdate(node, context, result, options);
363
322
  }
364
323
  }
365
324
 
@@ -426,112 +385,15 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
426
385
  }
427
386
 
428
387
  applyPlayer(player: Player): void {
429
- // TODO: Need a better mechanism for storing the current context.
430
- let currentContext: AsyncPluginContext | undefined = undefined;
431
- let parser: Parser | undefined = undefined;
432
-
433
- player.hooks.errorController.tap("async", (errorController) => {
434
- errorController.hooks.onError.tap("async", (playerError) => {
435
- if (currentContext === undefined) {
436
- return undefined;
437
- }
438
-
439
- /** Try to handle the error using the onAsyncNodeError hook. Returns true if new content is provided. */
440
- const tryHandleError = (asyncNode: Node.Async): boolean => {
441
- if (this.basePlugin === undefined) {
442
- player.logger.warn(
443
- `[AsyncNodePlugin]: No plugin detected. Error handling will fail`,
444
- );
445
- }
446
-
447
- let result: any = undefined;
448
- result = this.basePlugin?.hooks.onAsyncNodeError.call(
449
- playerError,
450
- asyncNode,
451
- );
452
-
453
- if (result === undefined) {
454
- return false;
455
- }
456
-
457
- player.logger?.warn(
458
- "[AsyncNodePlugin]: Async node handling failed and resolved with a fallback. Cause:",
459
- playerError.message,
460
- );
461
-
462
- // Stop tracking before the next update is triggered
463
- currentContext!.inProgressNodes.delete(asyncNode.id);
464
- this.parseNodeAndUpdate(
465
- asyncNode,
466
- currentContext!,
467
- result,
468
- parser?.parseObject.bind(parser),
469
- );
470
-
471
- return true;
472
- };
473
-
474
- const getNextNode = (node: Node.Node): Node.Node | undefined => {
475
- const parent =
476
- currentContext?.originalParentMap.get(node) ?? node.parent;
477
-
478
- if (!parent) {
479
- return undefined;
480
- }
481
-
482
- // 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
483
- return this.isAsync(parent)
484
- ? currentContext?.asyncNodeCache.get(parent.id)?.asyncNode
485
- : parent;
486
- };
487
-
488
- let node = getNodeFromError(playerError, currentContext);
489
- // If the node is an async node try, to handle errors with it first.
490
- if (node?.type === NodeType.Async && tryHandleError(node)) {
491
- return true;
492
- }
493
-
494
- // 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
495
- while (node !== undefined) {
496
- const generatedBy = currentContext.generatedByMap.get(node);
497
- if (generatedBy) {
498
- const entry = currentContext.asyncNodeCache.get(generatedBy);
499
-
500
- if (!entry) {
501
- node = getNextNode(node);
502
- continue;
503
- }
504
-
505
- const { asyncNode } = entry;
506
-
507
- // 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.
508
- if (tryHandleError(asyncNode)) {
509
- return true;
510
- }
511
- }
512
-
513
- node = getNextNode(node);
514
- }
515
-
516
- return undefined;
517
- });
518
- });
519
-
520
388
  player.hooks.viewController.tap("async", (viewController) => {
521
389
  viewController.hooks.view.tap("async", (view) => {
522
- view.hooks.parser.tap(this.name, (p) => {
523
- parser = p;
524
- });
525
390
  const context: AsyncPluginContext = {
391
+ nodeResolveCache: new Map(),
526
392
  inProgressNodes: new Set(),
527
393
  view,
528
394
  viewController,
529
- generatedByMap: new Map(),
530
- assetIdCache: new Map(),
531
- asyncNodeCache: new Map(),
532
- originalParentMap: new Map(),
395
+ originalNodeCache: new Map(),
533
396
  };
534
- currentContext = context;
535
397
 
536
398
  view.hooks.resolver.tap("async", (resolver) => {
537
399
  this.applyResolver(resolver, context);
@@ -2,5 +2,3 @@ export * from "./extractNodeFromPath";
2
2
  export * from "./traverseAndReplace";
3
3
  export * from "./unwrapAsset";
4
4
  export * from "./requiresAssetWrapper";
5
- export * from "./isAsyncPlayerError";
6
- export * from "./getNodeFromError";
package/types/index.d.ts CHANGED
@@ -1,9 +1,25 @@
1
- import type { Player, PlayerPlugin, Node, ViewInstance, Parser, ViewPlugin, Resolver } from "@player-ui/player";
1
+ import type { Player, PlayerPlugin, Node, ViewInstance, Parser, ViewPlugin, Resolver, ViewController } from "@player-ui/player";
2
2
  import { AsyncSeriesBailHook, SyncBailHook } from "tapable-ts";
3
- import { AsyncPluginContext } from "./internal-types";
4
3
  export * from "./types";
5
4
  export * from "./transform";
6
5
  export * from "./createAsyncTransform";
6
+ /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
7
+ * 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.
8
+ */
9
+ type AsyncPluginContext = {
10
+ /** Map of async node id to resolved content */
11
+ nodeResolveCache: Map<string, Node.Node>;
12
+ /** The view instance this context is attached to. */
13
+ view: ViewInstance;
14
+ /** The view controller this context is attached to. */
15
+ viewController: ViewController;
16
+ /** Map of async node id to promises being used to resolve them */
17
+ inProgressNodes: Set<string>;
18
+ /** Map of async node ids to the original node they represent.
19
+ * 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.
20
+ */
21
+ originalNodeCache: Map<string, Set<Node.Node>>;
22
+ };
7
23
  export interface AsyncNodePluginOptions {
8
24
  /** A set of plugins to load */
9
25
  plugins?: AsyncNodeViewPlugin[];
@@ -64,7 +80,6 @@ export declare class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
64
80
  */
65
81
  private handleAsyncUpdate;
66
82
  private hasValidMapping;
67
- private getOrCreateAsyncNodeCacheEntry;
68
83
  /**
69
84
  * Handles the asynchronous API integration for resolving nodes.
70
85
  * This method sets up a hook on the resolver's `beforeResolve` event to process async nodes.
@@ -2,6 +2,4 @@ export * from "./extractNodeFromPath";
2
2
  export * from "./traverseAndReplace";
3
3
  export * from "./unwrapAsset";
4
4
  export * from "./requiresAssetWrapper";
5
- export * from "./isAsyncPlayerError";
6
- export * from "./getNodeFromError";
7
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,30 +0,0 @@
1
- import {
2
- ErrorSeverity,
3
- type Node,
4
- type PlayerErrorMetadata,
5
- } from "@player-ui/player";
6
-
7
- export const ASYNC_ERROR_TYPE = "ASYNC-PLUGIN";
8
- export type AsyncErrorMetadata = {
9
- node: Node.Async;
10
- };
11
- export class AsyncNodeError
12
- extends Error
13
- implements PlayerErrorMetadata<AsyncErrorMetadata>
14
- {
15
- readonly type: string = ASYNC_ERROR_TYPE;
16
- readonly severity: ErrorSeverity = ErrorSeverity.ERROR;
17
- readonly metadata: AsyncErrorMetadata;
18
-
19
- constructor(
20
- node: Node.Async,
21
- message?: string,
22
- public readonly cause?: Error | undefined,
23
- ) {
24
- super(message);
25
-
26
- this.metadata = {
27
- node,
28
- };
29
- }
30
- }
@@ -1,30 +0,0 @@
1
- import type { Node, ViewController, ViewInstance } from "@player-ui/player";
2
-
3
- export type AsyncNodeInfo = {
4
- /** The async node */
5
- asyncNode: Node.Async;
6
- /** All nodes that need to be updated when the async content changes. This could be the async node itself, or a node that created it (i.e. an asset node that transformed into an async node). */
7
- updateNodes: Set<Node.Node>;
8
- /** The resolved content for this async node */
9
- resolvedContent?: Node.Node;
10
- };
11
-
12
- /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
13
- * 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.
14
- */
15
- export type AsyncPluginContext = {
16
- /** The view instance this context is attached to. */
17
- view: ViewInstance;
18
- /** The view controller this context is attached to. */
19
- viewController: ViewController;
20
- /** Set of async node ids that are currently being managed by an incomplete promise. */
21
- inProgressNodes: Set<string>;
22
- /** Map of async node ids to related info for that node. */
23
- asyncNodeCache: Map<string, AsyncNodeInfo>;
24
- /** Maps asset ids to its original node. Used to then search for what if could be generated by. */
25
- assetIdCache: Map<string, Node.Node>;
26
- /** Maps nodes to the async id that generated them. */
27
- generatedByMap: Map<Node.Node, string>;
28
- /** Map to track the original parent of nodes that were flattened into another. This is needed to track which async nodes actually generated these nodes. */
29
- originalParentMap: Map<Node.Node, Node.Node>;
30
- };