@player-ui/async-node-plugin 0.13.0-next.7 → 0.14.0-next.0

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.
@@ -0,0 +1,91 @@
1
+ import {
2
+ BeforeTransformFunction,
3
+ Builder,
4
+ Node,
5
+ NodeType,
6
+ } from "@player-ui/player";
7
+ import { extractNodeFromPath, traverseAndReplace, unwrapAsset } from "./utils";
8
+
9
+ export type AsyncTransformOptions = {
10
+ /** Whether or not to flatten the results into its container. Defaults to true */
11
+ flatten?: boolean;
12
+ /** The path to the array within the `wrapperAssetType` that will contain the async content. Defaults to ["values"] */
13
+ path?: string[];
14
+ /** The asset type that the transform is matching against. */
15
+ transformAssetType: string;
16
+ /** The asset type that will contain the async content. */
17
+ wrapperAssetType: string;
18
+ /** Function to get any nested asset that will need to be extracted and kept when creating the wrapper asset. */
19
+ getNestedAsset?: (node: Node.ViewOrAsset) => Node.Node | undefined;
20
+ /** Function to get the id for the async node being generated. Defaults to creating an id with the format of async-<ASSET.ID> */
21
+ getAsyncNodeId?: (node: Node.ViewOrAsset) => string;
22
+ };
23
+
24
+ const defaultGetNodeId = (node: Node.ViewOrAsset): string => {
25
+ return `async-${node.value.id}`;
26
+ };
27
+
28
+ /** Creates a BeforeTransformFunction that turns the given asset into a wrapper asset with an async node in it.
29
+ * By setting {@link AsyncTransformOptions.flatten} to true, you can chain multiple of the same asset type to create a flow of async content that
30
+ * exists within a single collection.
31
+ *
32
+ * @param options - Options for managing the transform
33
+ * @returns The {@link BeforeTransformFunction} that can be used for your asset.
34
+ */
35
+ export const createAsyncTransform = (
36
+ options: AsyncTransformOptions,
37
+ ): BeforeTransformFunction => {
38
+ const {
39
+ transformAssetType,
40
+ wrapperAssetType,
41
+ getNestedAsset,
42
+ getAsyncNodeId = defaultGetNodeId,
43
+ path = ["values"],
44
+ flatten = true,
45
+ } = options;
46
+
47
+ const replaceNode = (node: Node.Node): Node.Node => {
48
+ const unwrapped = unwrapAsset(node);
49
+
50
+ if (
51
+ unwrapped.type !== NodeType.Asset ||
52
+ unwrapped.value.type !== transformAssetType
53
+ ) {
54
+ return node;
55
+ }
56
+
57
+ const transformed = asyncTransform(unwrapped);
58
+ return extractNodeFromPath(transformed, path) ?? node;
59
+ };
60
+
61
+ const replacer = (node: Node.Node) => traverseAndReplace(node, replaceNode);
62
+
63
+ const asyncTransform = (node: Node.ViewOrAsset) => {
64
+ const id = getAsyncNodeId(node);
65
+ const asset = getNestedAsset?.(node);
66
+
67
+ // If flattening is disabled, don't need to extract the multi-node when async node is resolved.
68
+ const replaceFunction = flatten ? replacer : undefined;
69
+ const asyncNode = Builder.asyncNode(id, flatten, replaceFunction);
70
+
71
+ let multiNode: Node.MultiNode | undefined;
72
+
73
+ if (asset) {
74
+ const assetNode = Builder.assetWrapper(asset);
75
+ multiNode = Builder.multiNode(assetNode, asyncNode);
76
+ } else {
77
+ multiNode = Builder.multiNode(asyncNode);
78
+ }
79
+
80
+ const wrapperAsset: Node.ViewOrAsset = Builder.asset({
81
+ id: wrapperAssetType + "-" + id,
82
+ type: wrapperAssetType,
83
+ });
84
+
85
+ Builder.addChild(wrapperAsset, path, multiNode);
86
+
87
+ return wrapperAsset;
88
+ };
89
+
90
+ return asyncTransform;
91
+ };
package/src/index.ts CHANGED
@@ -13,10 +13,10 @@ import type {
13
13
  } from "@player-ui/player";
14
14
  import { AsyncParallelBailHook, SyncBailHook } from "tapable-ts";
15
15
  import queueMicrotask from "queue-microtask";
16
- import { omit } from "timm";
17
16
 
18
17
  export * from "./types";
19
18
  export * from "./transform";
19
+ export * from "./createAsyncTransform";
20
20
 
21
21
  /** Object type for storing data related to a single `apply` of the `AsyncNodePluginPlugin`
22
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.
@@ -46,8 +46,14 @@ export type AsyncHandler = (
46
46
  callback?: (result: any) => void,
47
47
  ) => Promise<any>;
48
48
 
49
+ export type AsyncContent = {
50
+ async: true;
51
+ flatten?: boolean;
52
+ [key: string]: unknown;
53
+ };
54
+
49
55
  /** Hook declaration for the AsyncNodePlugin */
50
- type AsyncNodeHooks = {
56
+ export type AsyncNodeHooks = {
51
57
  /** Async hook to get content for an async node */
52
58
  onAsyncNode: AsyncParallelBailHook<[Node.Async, (result: any) => void], any>;
53
59
  /** 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. */
@@ -131,9 +137,13 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
131
137
  result: any,
132
138
  options: Resolve.NodeResolveOptions,
133
139
  ) {
134
- const parsedNode =
140
+ let parsedNode =
135
141
  options.parseNode && result ? options.parseNode(result) : undefined;
136
142
 
143
+ if (parsedNode && node.onValueReceived) {
144
+ parsedNode = node.onValueReceived(parsedNode);
145
+ }
146
+
137
147
  this.handleAsyncUpdate(node, context, parsedNode);
138
148
  }
139
149
 
@@ -154,10 +164,20 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
154
164
  const { nodeResolveCache, view } = context;
155
165
  if (nodeResolveCache.get(node.id) !== newNode) {
156
166
  nodeResolveCache.set(node.id, newNode ? newNode : node);
157
- view.updateAsync();
167
+ view.updateAsync(node.id);
158
168
  }
159
169
  }
160
170
 
171
+ private hasValidMapping(
172
+ node: Node.Async,
173
+ context: AsyncPluginContext,
174
+ ): boolean {
175
+ const { nodeResolveCache } = context;
176
+ return (
177
+ nodeResolveCache.has(node.id) && nodeResolveCache.get(node.id) !== node
178
+ );
179
+ }
180
+
161
181
  /**
162
182
  * Handles the asynchronous API integration for resolving nodes.
163
183
  * This method sets up a hook on the resolver's `beforeResolve` event to process async nodes.
@@ -167,12 +187,12 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
167
187
  applyResolver(resolver: Resolver, context: AsyncPluginContext): void {
168
188
  resolver.hooks.beforeResolve.tap(this.name, (node, options) => {
169
189
  if (!this.isAsync(node)) {
170
- return node;
190
+ return node === null ? node : this.resolveAsyncChildren(node, context);
171
191
  }
172
192
 
173
193
  const resolvedNode = context.nodeResolveCache.get(node.id);
174
194
  if (resolvedNode !== undefined) {
175
- return resolvedNode;
195
+ return this.resolveAsyncChildren(resolvedNode, context);
176
196
  }
177
197
 
178
198
  if (context.inProgressNodes.has(node.id)) {
@@ -189,6 +209,63 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
189
209
  });
190
210
  }
191
211
 
212
+ /**
213
+ * Replaces child async nodes with their resolved content and flattens when necessary. Resolving the children directly helps manage the `parent` reference without needing as much work within the resolver itself.
214
+ * Handles async node chains as well to make sure all applicable nodes can get flattened.
215
+ * @param node - The node whose children need to be resolved.
216
+ * @param context - the async plugin context needed to reach into the cache
217
+ * @returns The same node but with async node children mapped to their resolved AST.
218
+ */
219
+ private resolveAsyncChildren(
220
+ node: Node.Node,
221
+ context: AsyncPluginContext,
222
+ ): Node.Node {
223
+ const asyncNodesResolved: string[] = node.asyncNodesResolved ?? [];
224
+ node.asyncNodesResolved = asyncNodesResolved;
225
+ if (node.type === NodeType.MultiNode) {
226
+ // Using a while loop lets us catch when async nodes produce more async nodes that need to be flattened further
227
+ let index = 0;
228
+ while (index < node.values.length) {
229
+ const childNode = node.values[index];
230
+ if (
231
+ childNode?.type !== NodeType.Async ||
232
+ !this.hasValidMapping(childNode, context)
233
+ ) {
234
+ index++;
235
+ continue;
236
+ }
237
+
238
+ const mappedNode = context.nodeResolveCache.get(childNode.id);
239
+ asyncNodesResolved.push(childNode.id);
240
+ if (mappedNode.type === NodeType.MultiNode && childNode.flatten) {
241
+ mappedNode.values.forEach((v: Node.Node) => (v.parent = node));
242
+ node.values = [
243
+ ...node.values.slice(0, index),
244
+ ...mappedNode.values,
245
+ ...node.values.slice(index + 1),
246
+ ];
247
+ } else {
248
+ node.values[index] = mappedNode;
249
+ mappedNode.parent = node;
250
+ }
251
+ }
252
+ } else if ("children" in node) {
253
+ node.children?.forEach((c) => {
254
+ // Similar to above, using a while loop lets us handle when async nodes produce more async nodes.
255
+ while (
256
+ c.value.type === NodeType.Async &&
257
+ this.hasValidMapping(c.value, context)
258
+ ) {
259
+ asyncNodesResolved.push(c.value.id);
260
+ c.value = context.nodeResolveCache.get(c.value.id);
261
+ c.value.parent = node;
262
+ }
263
+ });
264
+ }
265
+
266
+ return node;
267
+ }
268
+
192
269
  private async runAsyncNode(
193
270
  node: Node.Async,
194
271
  context: AsyncPluginContext,
@@ -234,8 +311,12 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
234
311
  return node?.type === NodeType.Async;
235
312
  }
236
313
 
237
- private isDeterminedAsync(obj: any) {
238
- return obj && Object.prototype.hasOwnProperty.call(obj, "async");
314
+ private isDeterminedAsync(obj: unknown): obj is AsyncContent {
315
+ return (
316
+ typeof obj === "object" &&
317
+ obj !== null &&
318
+ Object.prototype.hasOwnProperty.call(obj, "async")
319
+ );
239
320
  }
240
321
 
241
322
  applyParser(parser: Parser): void {
@@ -248,11 +329,9 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
248
329
  childOptions?: ParseObjectChildOptions,
249
330
  ) => {
250
331
  if (this.isDeterminedAsync(obj)) {
251
- const parsedAsync = parser.parseObject(
252
- omit(obj, "async"),
253
- nodeType,
254
- options,
255
- );
332
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
333
+ const { async, flatten, ...rest } = obj;
334
+ const parsedAsync = parser.parseObject(rest, nodeType, options);
256
335
  const parsedNodeId = getNodeID(parsedAsync);
257
336
 
258
337
  if (parsedAsync === null || !parsedNodeId) {
@@ -264,6 +343,7 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
264
343
  id: parsedNodeId,
265
344
  type: NodeType.Async,
266
345
  value: parsedAsync,
346
+ flatten,
267
347
  },
268
348
  obj,
269
349
  );
package/src/transform.ts CHANGED
@@ -2,11 +2,12 @@ import { Builder } from "@player-ui/player";
2
2
  import type { AsyncTransformFunc } from "./types";
3
3
 
4
4
  /**
5
+ * @deprecated Use {@link createAsyncTransform} to create your before transform function.
5
6
  * Util function to generate transform function for async asset
6
7
  * @param asset - async asset to apply beforeResolve transform
7
- * @param transformedAssetType: transformed asset type for rendering
8
8
  * @param wrapperAssetType: container asset type
9
9
  * @param flatten: flatten the streamed in content
10
+ * @param path: property path to add the multinode containing the next async node to
10
11
  * @returns - wrapper asset with children of transformed asset and async node
11
12
  */
12
13
 
@@ -15,10 +16,12 @@ export const asyncTransform: AsyncTransformFunc = (
15
16
  wrapperAssetType,
16
17
  asset,
17
18
  flatten,
19
+ path = ["values"],
18
20
  ) => {
19
21
  const id = "async-" + assetId;
20
22
 
21
23
  const asyncNode = Builder.asyncNode(id, flatten);
24
+
22
25
  let multiNode;
23
26
  let assetNode;
24
27
 
@@ -34,7 +37,7 @@ export const asyncTransform: AsyncTransformFunc = (
34
37
  type: wrapperAssetType,
35
38
  });
36
39
 
37
- Builder.addChild(wrapperAsset, ["values"], multiNode);
40
+ Builder.addChild(wrapperAsset, path, multiNode);
38
41
 
39
42
  return wrapperAsset;
40
43
  };
package/src/types.ts CHANGED
@@ -10,4 +10,5 @@ export type AsyncTransformFunc = (
10
10
  wrapperAssetType: string,
11
11
  asset?: Node.Node,
12
12
  flatten?: boolean,
13
+ path?: string[],
13
14
  ) => Node.Asset;
@@ -0,0 +1,181 @@
1
+ import { NodeType, Node } from "@player-ui/player";
2
+ import { describe, expect, it } from "vitest";
3
+ import { extractNodeFromPath } from "../extractNodeFromPath";
4
+
5
+ describe("extractNodeFromPath", () => {
6
+ it("should return any child with an exact match", () => {
7
+ const node: Node.Value = {
8
+ type: NodeType.Value,
9
+ value: {},
10
+ children: [
11
+ {
12
+ path: ["value", "asset"],
13
+ value: {
14
+ type: NodeType.Asset,
15
+ value: {
16
+ id: "id-1",
17
+ type: "type-1",
18
+ },
19
+ },
20
+ },
21
+ {
22
+ path: ["value"],
23
+ value: {
24
+ type: NodeType.Value,
25
+ value: {},
26
+ children: [
27
+ {
28
+ path: ["asset"],
29
+ value: {
30
+ type: NodeType.Asset,
31
+ value: {
32
+ id: "id-2",
33
+ type: "type-2",
34
+ },
35
+ },
36
+ },
37
+ ],
38
+ },
39
+ },
40
+ ],
41
+ };
42
+
43
+ const result = extractNodeFromPath(node, ["value", "asset"]);
44
+
45
+ expect(result).toStrictEqual({
46
+ type: NodeType.Asset,
47
+ value: {
48
+ id: "id-1",
49
+ type: "type-1",
50
+ },
51
+ });
52
+ });
53
+
54
+ it("should follow partial matches to find the path nested", () => {
55
+ const node: Node.Value = {
56
+ type: NodeType.Value,
57
+ value: {},
58
+ children: [
59
+ {
60
+ path: ["value"],
61
+ value: {
62
+ type: NodeType.Value,
63
+ value: {},
64
+ children: [
65
+ {
66
+ path: ["asset"],
67
+ value: {
68
+ type: NodeType.Asset,
69
+ value: {
70
+ id: "id-2",
71
+ type: "type-2",
72
+ },
73
+ },
74
+ },
75
+ ],
76
+ },
77
+ },
78
+ ],
79
+ };
80
+
81
+ const result = extractNodeFromPath(node, ["value", "asset"]);
82
+
83
+ expect(result).toStrictEqual({
84
+ type: NodeType.Asset,
85
+ value: {
86
+ id: "id-2",
87
+ type: "type-2",
88
+ },
89
+ });
90
+ });
91
+
92
+ const emptyPaths = [undefined, []];
93
+ it.each(emptyPaths)(
94
+ "should return the original node if the path is empty or undefined",
95
+ (path) => {
96
+ const node: Node.Value = {
97
+ type: NodeType.Value,
98
+ value: {},
99
+ children: [
100
+ {
101
+ path: ["value"],
102
+ value: {
103
+ type: NodeType.Value,
104
+ value: {},
105
+ children: [
106
+ {
107
+ path: ["asset"],
108
+ value: {
109
+ type: NodeType.Asset,
110
+ value: {
111
+ id: "id-2",
112
+ type: "type-2",
113
+ },
114
+ },
115
+ },
116
+ ],
117
+ },
118
+ },
119
+ ],
120
+ };
121
+
122
+ const result = extractNodeFromPath(node, path);
123
+
124
+ expect(result).toStrictEqual(node);
125
+ },
126
+ );
127
+
128
+ const noChildrenNodes: Node.Node[] = [
129
+ // No children property
130
+ {
131
+ id: "test",
132
+ type: NodeType.Async,
133
+ value: {
134
+ type: NodeType.Value,
135
+ value: {
136
+ id: "test",
137
+ },
138
+ },
139
+ },
140
+ // Children explicitly set to undefined
141
+ {
142
+ type: NodeType.Value,
143
+ value: {},
144
+ children: undefined,
145
+ },
146
+ ];
147
+
148
+ it.each(noChildrenNodes)(
149
+ "should return undefined if there are no children in the node",
150
+ (node) => {
151
+ const result = extractNodeFromPath(node, ["value", "asset"]);
152
+ expect(result).toBeUndefined();
153
+ },
154
+ );
155
+
156
+ it("should return undefined if there is no match", () => {
157
+ const node: Node.Value = {
158
+ type: NodeType.Value,
159
+ children: [
160
+ {
161
+ path: ["very", "long", "path"],
162
+ value: {
163
+ type: NodeType.Value,
164
+ value: {},
165
+ },
166
+ },
167
+ {
168
+ path: ["value", "not-asset"],
169
+ value: {
170
+ type: NodeType.Value,
171
+ value: {},
172
+ },
173
+ },
174
+ ],
175
+ value: {},
176
+ };
177
+
178
+ const result = extractNodeFromPath(node, ["value", "asset"]);
179
+ expect(result).toBeUndefined();
180
+ });
181
+ });
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { Node, NodeType } from "@player-ui/player";
3
+ import { traverseAndReplace } from "../traverseAndReplace";
4
+
5
+ describe("traverseAndReplace", () => {
6
+ it("should call the replace function against the given node if it is not a multi-node", () => {
7
+ const node: Node.Value = {
8
+ type: NodeType.Value,
9
+ value: {
10
+ prop: "value",
11
+ },
12
+ };
13
+
14
+ const replaceFunction = vi.fn();
15
+ replaceFunction.mockReturnValue({
16
+ type: NodeType.Value,
17
+ value: {
18
+ prop: "new-value",
19
+ },
20
+ });
21
+
22
+ const result = traverseAndReplace(node, replaceFunction);
23
+
24
+ expect(result).toStrictEqual({
25
+ type: "value",
26
+ value: {
27
+ prop: "new-value",
28
+ },
29
+ });
30
+ expect(replaceFunction).toHaveBeenCalledOnce();
31
+ expect(replaceFunction).toHaveBeenCalledWith({
32
+ type: "value",
33
+ value: {
34
+ prop: "value",
35
+ },
36
+ });
37
+ });
38
+
39
+ it("should call the replace function once for each value in the multi-node", () => {
40
+ const node: Node.MultiNode = {
41
+ type: NodeType.MultiNode,
42
+ values: [
43
+ {
44
+ type: NodeType.Value,
45
+ value: {
46
+ prop: "value-1",
47
+ },
48
+ },
49
+ {
50
+ type: NodeType.Value,
51
+ value: {
52
+ prop: "value-2",
53
+ },
54
+ },
55
+ ],
56
+ };
57
+
58
+ const replaceFunction = vi.fn();
59
+ replaceFunction.mockReturnValue({
60
+ type: NodeType.Value,
61
+ value: {
62
+ prop: "new-value",
63
+ },
64
+ });
65
+
66
+ const result = traverseAndReplace(node, replaceFunction);
67
+
68
+ expect(result).toStrictEqual({
69
+ type: "multi-node",
70
+ values: [
71
+ {
72
+ type: NodeType.Value,
73
+ value: {
74
+ prop: "new-value",
75
+ },
76
+ },
77
+ {
78
+ type: NodeType.Value,
79
+ value: {
80
+ prop: "new-value",
81
+ },
82
+ },
83
+ ],
84
+ });
85
+ expect(replaceFunction).toHaveBeenCalledTimes(2);
86
+ expect(replaceFunction).toHaveBeenCalledWith({
87
+ type: "value",
88
+ value: {
89
+ prop: "value-1",
90
+ },
91
+ });
92
+ expect(replaceFunction).toHaveBeenCalledWith({
93
+ type: "value",
94
+ value: {
95
+ prop: "value-2",
96
+ },
97
+ });
98
+ });
99
+
100
+ it("should flatten multi-node values generated by the replace function if the top-level node is a multi-node", () => {
101
+ const node: Node.MultiNode = {
102
+ type: NodeType.MultiNode,
103
+ values: [
104
+ {
105
+ type: NodeType.Value,
106
+ value: {
107
+ prop: "first",
108
+ },
109
+ },
110
+ ],
111
+ };
112
+
113
+ const replaceFunction = vi.fn();
114
+ replaceFunction.mockImplementation((node: Node.Node) => {
115
+ if (node.type === NodeType.Value && node.value.prop === "first") {
116
+ return {
117
+ type: NodeType.MultiNode,
118
+ values: [
119
+ {
120
+ type: NodeType.Value,
121
+ value: {
122
+ prop: "second",
123
+ },
124
+ },
125
+ {
126
+ type: NodeType.Value,
127
+ value: {
128
+ prop: "third",
129
+ },
130
+ },
131
+ ],
132
+ };
133
+ }
134
+
135
+ return {
136
+ type: NodeType.Value,
137
+ value: {
138
+ prop: "new-value",
139
+ },
140
+ };
141
+ });
142
+
143
+ const result = traverseAndReplace(node, replaceFunction);
144
+
145
+ expect(result).toStrictEqual({
146
+ type: "multi-node",
147
+ values: [
148
+ {
149
+ type: NodeType.Value,
150
+ value: {
151
+ prop: "new-value",
152
+ },
153
+ },
154
+ {
155
+ type: NodeType.Value,
156
+ value: {
157
+ prop: "new-value",
158
+ },
159
+ },
160
+ ],
161
+ });
162
+ expect(replaceFunction).toHaveBeenCalledTimes(3);
163
+ expect(replaceFunction).toHaveBeenCalledWith({
164
+ type: "value",
165
+ value: {
166
+ prop: "first",
167
+ },
168
+ });
169
+ expect(replaceFunction).toHaveBeenCalledWith({
170
+ type: "value",
171
+ value: {
172
+ prop: "second",
173
+ },
174
+ });
175
+ expect(replaceFunction).toHaveBeenCalledWith({
176
+ type: "value",
177
+ value: {
178
+ prop: "third",
179
+ },
180
+ });
181
+ });
182
+ });