@player-ui/async-node-plugin 1.1.0-next.5 → 1.1.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.
@@ -684,6 +684,143 @@ describe("view", () => {
684
684
  expect(view?.actions[1]).toBeUndefined();
685
685
  });
686
686
 
687
+ test("preserves callback-streamed content when the hook resolves with undefined", async () => {
688
+ // Regression: a streaming consumer pushes content through the callback while
689
+ // the hook is still pending, then resolves the hook with nothing. Previously
690
+ // the bail path re-parsed the undefined return (last-writer-wins) and
691
+ // reverted the streamed content back to the async placeholder.
692
+ let deferredResolve: ((value: any) => void) | undefined;
693
+ let updateContent: ((content: any) => void) | undefined;
694
+
695
+ const plugin = new AsyncNodePlugin({
696
+ plugins: [new AsyncNodePluginPlugin()],
697
+ });
698
+
699
+ plugin.hooks.onAsyncNode.tap(
700
+ "test",
701
+ async (_node: Node.Async, update: (content: any) => void) => {
702
+ updateContent = update;
703
+ // Resolve the hook with nothing; content is delivered via the callback.
704
+ return new Promise((resolve) => {
705
+ deferredResolve = resolve;
706
+ });
707
+ },
708
+ );
709
+
710
+ const player = new Player({ plugins: [plugin] });
711
+
712
+ player.start(basicFRFWithActions as any);
713
+
714
+ let view = (player.getState() as InProgressState).controllers.view
715
+ .currentView?.lastUpdate;
716
+
717
+ expect(view).toBeDefined();
718
+ expect(view?.actions[1]).toBeUndefined();
719
+
720
+ // Wait until the hook has run and handed us the streaming callback.
721
+ await waitFor(() => {
722
+ expect(updateContent).toBeDefined();
723
+ expect(deferredResolve).toBeDefined();
724
+ });
725
+
726
+ // Stream content through the callback while the hook promise is still pending.
727
+ updateContent?.({
728
+ asset: {
729
+ id: "next-label-action",
730
+ type: "action",
731
+ value: "streamed value",
732
+ },
733
+ });
734
+
735
+ await waitFor(() => {
736
+ view = (player.getState() as InProgressState).controllers.view.currentView
737
+ ?.lastUpdate;
738
+ expect(view?.actions[1]?.asset.type).toBe("action");
739
+ });
740
+
741
+ // Now resolve the hook with nothing. The streamed content must survive.
742
+ deferredResolve?.(undefined);
743
+
744
+ await waitFor(() => {
745
+ view = (player.getState() as InProgressState).controllers.view.currentView
746
+ ?.lastUpdate;
747
+ expect(view?.actions[1]?.asset.type).toBe("action");
748
+ });
749
+
750
+ view = (player.getState() as InProgressState).controllers.view.currentView
751
+ ?.lastUpdate;
752
+ expect(view?.actions[0].asset.type).toBe("action");
753
+ expect(view?.actions[1]?.asset.type).toBe("action");
754
+ });
755
+
756
+ test("callback-streamed content is not overwritten by a stale hook return value", async () => {
757
+ // Regression: a streaming consumer pushes content through the callback while
758
+ // the hook is still pending, then resolves the hook with a different (stale)
759
+ // value. The callback is the source of truth once used, so the bail return
760
+ // must not overwrite it — otherwise the streamed row is intermittently
761
+ // replaced by the stale content.
762
+ let deferredResolve: ((value: any) => void) | undefined;
763
+ let updateContent: ((content: any) => void) | undefined;
764
+
765
+ const plugin = new AsyncNodePlugin({
766
+ plugins: [new AsyncNodePluginPlugin()],
767
+ });
768
+
769
+ plugin.hooks.onAsyncNode.tap(
770
+ "test",
771
+ async (_node: Node.Async, update: (content: any) => void) => {
772
+ updateContent = update;
773
+ return new Promise((resolve) => {
774
+ deferredResolve = resolve;
775
+ });
776
+ },
777
+ );
778
+
779
+ const player = new Player({ plugins: [plugin] });
780
+
781
+ player.start(basicFRFWithActions as any);
782
+
783
+ await waitFor(() => {
784
+ expect(updateContent).toBeDefined();
785
+ expect(deferredResolve).toBeDefined();
786
+ });
787
+
788
+ // Stream the authoritative content through the callback.
789
+ updateContent?.({
790
+ asset: {
791
+ id: "streamed-action",
792
+ type: "action",
793
+ value: "streamed value",
794
+ },
795
+ });
796
+
797
+ let view: any;
798
+ await waitFor(() => {
799
+ view = (player.getState() as InProgressState).controllers.view.currentView
800
+ ?.lastUpdate;
801
+ expect(view?.actions[1]?.asset.id).toBe("streamed-action");
802
+ });
803
+
804
+ // Resolve the hook with a stale, non-null value. It must not win.
805
+ deferredResolve?.({
806
+ asset: {
807
+ id: "stale-action",
808
+ type: "action",
809
+ value: "stale value",
810
+ },
811
+ });
812
+
813
+ await waitFor(() => {
814
+ view = (player.getState() as InProgressState).controllers.view.currentView
815
+ ?.lastUpdate;
816
+ expect(view?.actions[1]?.asset.id).toBe("streamed-action");
817
+ });
818
+
819
+ view = (player.getState() as InProgressState).controllers.view.currentView
820
+ ?.lastUpdate;
821
+ expect(view?.actions[1]?.asset.id).toBe("streamed-action");
822
+ });
823
+
687
824
  test("replaces async nodes with provided node", async () => {
688
825
  const plugin = new AsyncNodePlugin({
689
826
  plugins: [new AsyncNodePluginPlugin()],
package/src/index.ts CHANGED
@@ -315,16 +315,33 @@ export class AsyncNodePluginPlugin implements AsyncNodeViewPlugin {
315
315
  options: Resolve.NodeResolveOptions,
316
316
  ) {
317
317
  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
+
318
323
  const result = await this.basePlugin?.hooks.onAsyncNode.call(
319
324
  node,
320
- (result) => {
321
- this.parseNodeAndUpdate(node, context, result, options.parseNode);
325
+ (streamedResult) => {
326
+ deliveredViaCallback = true;
327
+ this.parseNodeAndUpdate(
328
+ node,
329
+ context,
330
+ streamedResult,
331
+ options.parseNode,
332
+ );
322
333
  },
323
334
  );
324
335
 
325
336
  // Stop tracking before the next update is triggered
326
337
  context.inProgressNodes.delete(node.id);
327
- this.parseNodeAndUpdate(node, context, result, options.parseNode);
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
+ }
328
345
  } catch (e: unknown) {
329
346
  const cause = e instanceof Error ? e : new Error(String(e));
330
347
  const playerState = this.basePlugin?.getPlayerInstance()?.getState();