Search code examples
angularangular-materialangular-changedetectionangular-dynamic-componentsangular-factory

Angular - Dynamic Component Rendering Wrong Data


Solved

As mentioned below by Aaron, the "Other Option" approach works without issues and feels a lot cleaner. I'll leave the updates and everything in case anyone in the future has a similar problem and the workarounds provide useful somehow.


I'm having a weird problem trying to render dynamic components inside an Angular material table with pagination and sort.

Here is a Stackblitz with the reproduced issue. From what I've researched, it seems to be some sort of change detection issue, although I could be wrong on that since I have no idea why it's happening. If anyone has any ideas on how to fix this issue, or if I'm doing something blatantly wrong, feedback and help would be highly appreciated.

Current Behavior:

I'm using @ViewChildren and ComponentFactoryResolver inside a material design table to render components dynamically with a few pages of data. Everything looks fine, however when using matSort to sort the data, some of the rows that have dynamic components show the wrong data values, while the rest of the row (non-dynamic) has the correct ones. See screenshots below:

Before sorting:

2

After sorting:

3

console log of component instances (note the correct status field is passed, yet the wrong one is rendered).

3

Expected Behavior:

Data and rendered components are shown accurately when I sort. Should match the dataSource.data field.

Minimal Reproduction of the Problem with Instructions:

Visit the Stackblitz and run the application. Click the sort header for "Order Status". Notice how the first entry in the table says "Delivered" while the actual dataSource has it as "Cancelled. Also not that the rest of the row is accurate - just the dynamically rendered component is incorrect.

What I've Tried:

  • Tried setting the status with a get() and set() instead of an @Input() and register an ngOnChage, where I call a changeDetectorRef from the component itself, but the issue persisted.

  • Tried changing the ChangeDetectionStrategy - neither one did anything.

  • Tried the solutions in the below section as well

Possible Related Posts I've Looked at Without Luck of Mentioned Solutions

This seems to be the same issue, however it was never answered. I'm hoping providing a Stackblitz will be helpful in getting the same answer. It seems to be some sort of change detection issue according to the poster.

When loading same component with different data, old data still showing - Angular 5

Tried explicitly calling the detectChanges() and markForCheck() chanceDetectionRef methods for the componentRefs, the parent component, the actual component that gets dynamically created with no luck.

Angular dynamic component loader change detection issue

Change detection not working when creating a component via ComponentFactoryResolver

Possible Workaround / More Weird Behavior:

If you sort by Order Status and get the wrong value to be in the first row as before, and then paginate to the next page and back, component shows the correct value. I'm assuming the pagination forces some sort of change detection in the material table, or the templates, or something along those lines, but I have no idea why the sort wouldn't do that. The current work around I have going is to just paginate to the next page and back, however that is by no means an acceptable solution, and I'd really like to understand what I'm doing wrong here, or what a fix for this is.

Update 1

As per Aaron's additional insight below, I've added an Updated Stackblitz with a far more simplified example. On a closer look through, pagination doesn't seem to play a factor into the weird behavior. I can reproduce the issue with just 2 table entries and a sort (all pagination code removed). I've changed up the data coming in to match across the rows so it's clearer which rows come from where.

Here's a combined screenshot of before and after sorting with 2, 3, and 4, records:

enter image description here

I'm currently trying to see if I can force the table to re-render (assuming it's possibly shifting things around), and eventually going to dig through the source code for the table.

Update 2

One workaround I was able to figure out is to set my dataSource to an empty array after I've saved a copy of it with the splice() in the onSortChange() right before doing my filtering logic, then setting it back to the filtered array. I'm assuming that triggers some sort of update in the material datasource/table which forces a re-render or update. I'll keep trying to figure out if there's a better approach to this, since it seems weird to be doing that, and in my original application I have a filterPredicate which also causes the same issue when filtering/resetting the filter.

Update 3

Aaaaaand finally after some more research on this, I've come to at least somewhat of a solution that makes sense. After what I said on Update #2, I found this post:

https://github.com/angular/components/issues/15972#issuecomment-490235603

which explains why the table wasn't getting the updated dataSource. In my case it was probably a lot harder to figure that out since I was doing a sort/filter, and didn't think it would be directly tied to that. I'll still explore a few more ideas in the next few days, but if neither work, I'll go ahead and mark this as solved after that.


Solution

  • I'm not 100% sure what is going on, but playing around with it a bit, I don't think the issue is change detection. I started by commenting out everything after the

    cellTemplateRef.clear(); in createDynamicComponents() in the dynamic-table.components.ts file.

    Doing this, definitely would see everything in that column disappear.

    Then taking the other approach of only commenting out the cellTemplateRef.clear(), you'll see your new components get shoved in on top of the old one.

    enter image description here

    Next step was the remove the old component after you added the new one...

    After the Object.keys.... loop:

        if (cellTemplateRef.length > 1) {
          cellTemplateRef.remove(1);
        } 
    

    No idea why the clear() doesn't do the job, but removing the clear and adding the direct removal of the component instance appears to be a workaround.

    Noticing that this solution isn't even sorting properly, here is some more insight into what is going on. Add an @Input() index on your StatusIconComponent and display it in your component. Then in your loop...

    cellComponentRef.instance["index"] = i;
    

    After sorting you will see something like this prior to sorting:

    enter image description here

    And then after sort...

    enter image description here

    Notice that the rows are not rendering in the order you are expecting.

    With that, I suspect that you don't even have to add/remove components all of time. They may not be re-rendering the entire row, perhaps just moving it.

    This still doesn't answer your question, but hopefully provides more insight.

    Other Option

    My suspicion is that the order of the ViewChildren on the ng-template in the data cells is not what you would hope. So instead of doing templates in the table, how about creating a DynamicCellComponent that you do all of the component factory magic in.

    So your table template would look something like this...

    <td mat-cell *matCellDef="let data">
      <!-- moving your ngIfs to the component -->
    
      <app-dynamic-cell [columnDef]="column" [data]="data"></app-dynamic-cell>
    </td>
    

    Then create a component similar to this (basically moved most of your code over from the table).

    @Component({
      selector: "app-dynamic-cell",
      templateUrl: "./dynamic-cell.component.html",
      styleUrls: ["./dynamic-cell.component.css"]
    })
    export class DynamicCellComponent implements AfterViewInit {
      @Input() columnDef: DynamicTableColumn;
      @Input() data: any;
    
      @ViewChild("container", { read: ViewContainerRef })
      private container: ViewContainerRef;
    
      constructor(private componentFactoryResolver: ComponentFactoryResolver, private cdr: ChangeDetectorRef) {}
    
      isValueDynamicComponent(value: any) {
        return !(typeof value === "function");
      }
    
      ngAfterViewInit() {
        console.log(this.columnDef, this.data);
        const viewContainerRef = this.container;
        if (viewContainerRef) {
          viewContainerRef.clear();
    
          const { type: componentType, inputs: inputsFn } = (this.columnDef
            .value as any) as DynamicComponent;
    
          const componentInputs = inputsFn(this.data);
    
          // create the component instance and store a reference to it
          const componentFactory = this.componentFactoryResolver.resolveComponentFactory(
            componentType
          );
    
          const cellComponentRef = viewContainerRef.createComponent<
            typeof componentType
          >(componentFactory);
    
          // populate any properties of the component
          Object.keys(componentInputs).forEach(key => {
            cellComponentRef.instance[key] = componentInputs[key];
          });
    
          this.cdr.detectChanges();
        }
      }
    }
    

    And then the component html do what you were doing in the cell definition before.

    <ng-container *ngIf="!isValueDynamicComponent(columnDef.value)">{{ columnDef.value(data)}}</ng-container>
    <ng-container *ngIf="isValueDynamicComponent(columnDef.value)"#container></ng-container>
    

    This way, you are not concerning yourself with the internal workings of the data table, and the order things are rendered etc.