*ngFor not updating when data is received through a BroadcastChannel

353 views Asked by At

I was creating this stackblitz to get help with a different problem and ran into the problem I'm having now in the process. I'm using a BroadcastChannel to send data to a component embedded inside of an <iframe> with ViewContanierRef.createComponenent(). Everything works as I expect it to however my *ngFor condition isn't iterating the data.

If you open the stackblitz you'll see a show data button inside the <iframe>. I made that to console.log() the data but clicking on it somehow makes it iterate in the *ngFor. Beneath the <iframe> you'll see the ControllerComponent with a resend button. The ControllerComponent will ultimately be an interface for interacting with the component embedded into the <iframe> through the BroadcastChannel. Of course we have to consider the lifecycle hooks in doing things like this so the process I created flows as follows.

  1. Pass a name and a type name into @Input()s on both the ControllerComponent and the EmbeddedViewComponent which will create the BroadcastChannels in their service files.

  2. In the AfterViewChecked hook of the EmbeddedViewComponent I fire a function in the service file that sends a notification through the BroadcastChannel to the ControllerComponent so that it knows the component already exists before it attempts to send data.

  3. Inside the .onMessage() method that receives the notification another function is fired that broadcasts the data to the EmbeddedViewComponent where it's stored in a BehaviorSubject which is subscribed to in the OnInit hook and is what's iterated by the *ngFor.

I tried adding an | async pipe to the *ngFor condition just to see if that made any difference and got an error as I expected. I tried moving things to different lifecycle hooks just for it to do the same thing or give an undefined error as the component and BroadcastChannel didn't exist yet.

As far as the mechanism I'm creating, I'm building a component for demonstrating different responsive design examples which I'm using the <iframe> to do to take advantage of the separate DOM instance which the user can scaleup and down without having to scale the entire browser. Due to the purpose of this component I have to build it in consideration to the fact that it will never know what component it will have to embed or controller it will have to provide for the user. So the way I have it set up to work goes as follows.

ResponsiveFrameComponent.component.ts

@Component({
  selector    : 'responsive-frame',
  templateUrl : './responsive-frame.component.html',
  styleUrls   : ['./responsive-frame.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})

export class ResponsiveFrameComponent implements OnInit, AfterContentChecked {

  //    The component that will be embedded into the iframe.
  @Input() ComponentToEmbed : Type<Component>;
  
  //    The name I want to use for the BroadcastChannel
  @Input() DataChannelName : string = '';

  //    The name of the Type I want the .onMessage() method to check for
  @Input() DataChannelType : string = '';

  //    The iframe the component gets embedded into
  @ViewChild('responsiveWindow', {static : true} ) ResponsiveWindow : ElementRef;

  constructor( private vcRef: ViewContainerRef, private cdr: ChangeDetectorRef ){}

  ngOnInit():void {

    this.embedComponent();
  }

  ngAfterContentChecked(): void {
    this.cdr.detectChanges();
  }



  public embedComponent(): void {
   
    //    Targets the iframe
    const frame = this.ResponsiveWindow?.nativeElement.contentDocument || this.ResponsiveWindow?.nativeElement.contentWindow;


    //    The instance of the component to be embedded
    const componentInstance : ComponentRef<any> = this.vcRef.createComponent( this.ComponentToEmbed );

    
    //    Checks to see if the component being embedded has an @Input() named DataChannelName
    if(
        this.DataChannelName !== '' &&
        (<ComponentRef<any>>componentInstance.instance).hasOwnProperty('DataChannelName')
      ){
        
        //    If the component has a DataChannelName @Input() then pass the channel name and
        //    type into the component instance.  
        componentInstance.instance.DataChannelName = this.DataChannelName;
        componentInstance.instance.DataChannelType = this.DataChannelType;
      }

    //    css stuff
    const domStyles = document.createElement( 'style' );
    
    domStyles.innerText = "* { padding : 0; margin : 0; box-sizing: border-box; overflow: hidden; } body { display : grid; place-items: center; min-height: 0px; max-height: 100vh; grid-template-columns: 1fr; grid-template-rows: 1fr; } ";

    frame.head.appendChild( domStyles );

    //    Embeds component into iframe
    frame.body.appendChild( componentInstance.location.nativeElement );
  }

}

ResponsiveFrameComponent.component.html

<article class="iFrameShell">
  <iframe #responsiveWindow class="iFrame"></iframe>
</article>

<!--  I use content projection to insert a controller component  -->
<ng-content select="[controlFrame]"></ng-content>

and where I want to use the ResponsiveFrameComponent I just do this

<!--  The EmbedElement is a variable where I store the component to embed  -->
<responsive-frame
  [ComponentToEmbed]="EmbedElement"
  [DataChannelName]="'dataChannelA'"
  [DataChannelType]="'propA'"
  [FrameWidth]="400"
  [FrameHeight]="400"
>

  <!--  the DataObjectA is a variable that stores the data I want the controller to use  -->
  <controller-component
    controlFrame
    class="controlFrame"
    [DataChannelName]="'dataChannelA'"
    [DataChannelType]="'propA'"
    [Value]="DataObjectA"
  ></controller-component>

</responsive-frame>

As I mentioned before the embedded component will be what sends a notification to the controller which is set up like this

EmbeddedViewComponent

@Component({
  selector:    'embedded-view',
  templateUrl: './embedded-view.component.html',
  styleUrls:   ['./embedded-view.component.css'],
  providers:   [EmbeddedViewService],
  encapsulation: ViewEncapsulation.ShadowDom,
  changeDetection: ChangeDetectionStrategy.OnPush
})

export class EmbeddedViewComponent implements OnInit, AfterContentChecked {
  @Input() DataChannelName : string = '';
  @Input() DataChannelType : string = '';

  //    For indicating if the component is loaded or not
  Loaded: boolean = false;
  Data: string[] = [];

  constructor(private service: EmbeddedViewService, private cdr: ChangeDetectorRef) {}

  ngOnInit(): void {

    //    creates the BroadcastChannel
    this.service.initDataChannel(this.DataChannelName, this.DataChannelType);

    //    subscribes to the BehaviorSubject
    this.service.getData().subscribe(a => this.Data = a);
    
  }
  
  ngAfterContentChecked(): void{

    if(!this.Loaded){
      this.Loaded = true;

      //    Sends the notification to the controller
      this.service.sendNotification();
    }

    this.cdr.detectChanges();

  }

  public showData(){console.log(this.Data);}
}

EmbeddedViewComponent.service.ts

export class EmbeddedViewService{

  //    Stores data from controller 
  Data: BehaviorSubject<string[]> = new BehaviorSubject([]);

  //    returns Data as an Observable for subscription
  DataObserver: Observable<string[]> = this.Data.asObservable();

  //    The Broadcast channel that will be created
  DataChannel!: BroadcastChannel;
  DataChannelName!: string;
  DataChannelType!: string;

  constructor(){}

  //    Fired in the OnInit hook to set everything up for communicating with the controller
  public initDataChannel(channel: string, type: string):void {

    //    Creates the BroadcastChannel
    this.DataChannel = new BroadcastChannel(channel);

    //    Stores channel name and type locally to be checked against later
    this.DataChannelName = channel;
    this.DataChannelType = type;

    //    creates onMessage event for handling data from the controller
    this.listenForMessage();
  }

  //    Updates the BehaviorSubject
  private setData(value: string[]): void{ this.Data.next(value); }

  //    Returns the Observable of the BehaviorSubject
  public getData(): Observable<string[]>{ return this.DataObserver; }

  //    sends a message through the BroadcastChannel to indicate
  public sendNotification(): void{

    this.DataChannel.postMessage({
      type: 'verification',
      data: 'complete'
    });

  }

  public listenForMessage(): void{

    this.DataChannel.onmessage = (ev) =>{

      if(ev.data.type === this.DataChannelType){
        this.setData(ev.data.data);
      }

    };

  }
   
}

And the template looks like this

EmbeddedViewComponent.component.html

<article *ngFor="let i of Data">

  <p>{{i}}</p>

</article>

<p>embed component</p>

<button type="button" (click)="showData()">show data</button>

The ControllerComponent is pretty much the same type of setup which looks like this

ControllerComponent.component

export class ControllerComponent implements OnInit, AfterViewInit {

  //    The name of the broadcast channel to communicate through
  @Input() DataChannelName : string   = '';

  //    The type to use on the channel which will be explained further
  @Input() DataChannelType : string   = '';

  //    The data I want to use and cast to the channel
  @Input() Value : string[] = [];

  constructor(private service: ControllerService) {}

  ngOnInit(): void {

    //    Sets up the BroadcastChannel in the service file and stores the type to a variable
    this.service.setChannel(this.DataChannelName, this.DataChannelType);

    //    Sends data to service file to have available to send to the embedded component
    this.service.setProperty(this.Value);
  }
  
  ngAfterViewInit(): void {}

  public resendData():void{
    this.service.sendData();
  }

}

ControllerComponent.service

export class ControllerService {

  //    Same as in the EmbeddedViewComponent
  DataChannel!: BroadcastChannel;
  DataChannelName!: string;
  DataChannelType!: string;

  //    The data set in the OnInit hook
  DataToSend: string[] = [];

  //    Sets up the BroadcastChannel
  public setChannel(channel: string, type: string): void {

    this.DataChannel = new BroadcastChannel(channel);
    this.DataChannelName = channel;
    this.DataChannelType = type;

    //    Creates onMessage event to confirm when the EmbeddedViewComponent is loaded
    this.listenForVerification();

  }

  //    Stores Data sent in OnInit hook of the component
  public setProperty(property: string[]): void {
    this.DataToSend = property;
  }
  
  public listenForVerification(): void {

    this.DataChannel.onmessage = (ev) => {

      if (ev.data.type === 'verification' && ev.data.data === 'complete') {

        //    Sends the data to the EmbeddedViewComponent
        this.sendData();

      }

    };

  }

  public sendData(): void {
    this.DataChannel.postMessage({
      type: this.DataChannelType,
      data: this.DataToSend,
    });
  }

}

As I stated before, everything works as expected up to the point of the data being iterated with the *ngFor. Does anybody see what causes this is happening?

1

There are 1 answers

1
Optiq On

As I was looking through my notebook at some other uses of BroadcastChannel I came across a function I made that used NgZone which reminded me that I needed to do that so Angular can know the data update is happening. So I refactored the .onMessage() method like this

this.DataChannel.onmessage = (ev) =>{

      //   Updating inside NgZone so Angular can know it's happening
      this.zone.run(()=>{

        if(ev.data.type === this.DataChannelType){
          this.setData(ev.data.data);
        }
      });
    };

That made it work as expected as you can see in this stackblitz.