Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

bug(core): Error updating transplanted view count with a null parent view #38619

Closed
Achilles1515 opened this issue Aug 27, 2020 · 7 comments
Closed
Assignees
Labels
area: core Issues related to the framework runtime core: change detection core: dynamic view creation freq1: low regression Indicates than the issue relates to something that worked in a previous version type: bug/fix
Milestone

Comments

@Achilles1515
Copy link

馃悶 bug report

Affected Package

The issue is caused by package @angular/core

Is this a regression?

Yes, the previous version in which this bug was not present was: I don't know. The error is occurring with Ivy and not View Engine.

Description

I am working with dynamic views and decided to add an embeddedViewRef.detectChanges() call in my app which unexpectedly spit out the following error:

core.js:4197 ERROR TypeError: Cannot read property '5' of null at updateTransplantedViewCount (core.js:2425) at refreshView (core.js:7268) at detectChangesInternal (core.js:8506) at ViewRef.detectChanges (core.js:9864)

This error is occurring in the first line of the updateTransplantedViewCount function:

export function updateTransplantedViewCount(lContainer: LContainer, amount: 1|- 1) {
lContainer[TRANSPLANTED_VIEWS_TO_REFRESH] += amount;

due to the LContainer input variable being null.

And updateTransplantedViewCount is being called inside refreshView here:

if (lView[FLAGS] & LViewFlags.RefreshTransplantedView) {
lView[FLAGS] &= ~LViewFlags.RefreshTransplantedView;
updateTransplantedViewCount(lView[PARENT] as LContainer, -1);
}
} finally {
leaveView();
}
}

...so lView[PARENT] here is resolving to null.

The obvious first reaction is "well, let's add a null check to one of these functions"...but I'm not familiar enough with the Ivy internals to know if lView[PARENT] being null alongside LViewFlags.RefreshTransplantedView being set represents some larger architecture issue (i.e. this scenario should be "impossible").

For a little more context, I am calling embeddedViewRef.detectChanges() on a non-destroyed embedded view ref that is not attached to a view container ref (was previously attached, but had been detached).

馃敩 Minimal Reproduction

Currently struggling to make a repro. If I can't get it to repro, then I'll upload a snapshot of my code.

馃實 Your Environment

Angular Version:


Angular CLI: 10.0.8
Node: 14.8.0
OS: win32 x64

Angular: 10.0.14
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router
Ivy Workspace: Yes

Package                            Version
------------------------------------------------------------
@angular-devkit/architect          0.1000.8
@angular-devkit/build-angular      0.1000.8
@angular-devkit/build-ng-packagr   0.1000.8
@angular-devkit/build-optimizer    0.1000.8
@angular-devkit/build-webpack      0.1000.8
@angular-devkit/core               10.0.8
@angular-devkit/schematics         10.0.8
@angular/cli                       10.0.8
@ngtools/webpack                   10.0.8
@schematics/angular                10.0.8
@schematics/update                 0.1000.8
ng-packagr                         10.1.0
rxjs                               6.5.5
typescript                         3.9.7
webpack                            4.43.0

@Achilles1515 Achilles1515 changed the title bug(core): bug(core): Error updating transplanted view count with a null parent view Aug 27, 2020
@Achilles1515
Copy link
Author

Pinging @atscott as I know you did some work with transplanted views recently.

@josephperrott josephperrott added the area: core Issues related to the framework runtime label Aug 27, 2020
@ngbot ngbot bot added this to the needsTriage milestone Aug 27, 2020
@atscott atscott added freq1: low regression Indicates than the issue relates to something that worked in a previous version labels Aug 27, 2020
@atscott atscott self-assigned this Aug 27, 2020
@ngbot ngbot bot modified the milestones: needsTriage, Backlog Aug 27, 2020
@atscott
Copy link
Contributor

atscott commented Aug 27, 2020

Ah, I think this could happen if the view was previously attached, then manually detached, then you call detectChanges on it. The detachMovedView has code to handle updating the parent container count on detach

// If the view was marked for refresh but then detached before it was checked (where the flag
// would be cleared and the counter decremented), we need to decrement the view counter here
// instead.
if (lView[FLAGS] & LViewFlags.RefreshTransplantedView) {
updateTransplantedViewCount(insertionLContainer, -1);
}

but if you still have a handle on the embedded view and call detectChanges, it looks like this could cause the error to happen. I hadn't really considered that use-case. I think it's likely that re-attaching that embedded view would also not be handled correctly because trackMovedView doesn't update the parent counters for a transplanted view that is already dirty.

A reproduction would be really great! I'll need one in order to test that a fix works. I might be able to make one from this information, but that might only be possible if my assumptions about why this broke are correct.

@atscott
Copy link
Contributor

atscott commented Aug 28, 2020

Reproduction of error, though the scenario is totally bonkers so it would be nice to get a more real-world scenario: https://stackblitz.com/edit/dynamic-views-qflhmd?file=src%2Fapp%2Fapp.component.ts
Steps to reproduce:

  1. click toggle content to create embedded view
  2. click detect changes

What happens in the reproduction is as follows:

  • Template ref created at the top level
  • Template ref passed down two levels from app component: app component -> middle component -> app container
    What this does is create the "transplanted view" scenario. i.e. when app component is dirty and change detection runs, we need to mark its templates which are inserted elsewhere as needing a check.
  • Call detect changes on app component. refreshView runs on app component, which also marks transplanted view trees as needing a check
    • Middle component gets checked as well because it's CheckAlways. As part of the template execution, it detaches the embedded view before the container component is checked
    • Then, after the embedded view gets detached, we manually call detectChanges on it. Because it was removed before its insertion component was checked, the RefreshTransplantedView never got cleared. Calling detectChanges will result in the view attempting to decrement the counters on the parent containers but there aren't any because it was detached.

The most obvious fix to me would be to also clear RefreshTransplantedView flag in addition to reducing transplanted view counter on the parens when the view gets detached.

@Achilles1515
Copy link
Author

@atscott
I was able to make a super simple reproduction.
StackBlitz

The entire code is as follows:

export class AppComponent  {
  @ViewChild(TemplateRef, {static: true}) templateRef: TemplateRef<any>;
  viewRef: EmbeddedViewRef<any>;

  constructor(public vref: ViewContainerRef, public cdr: ChangeDetectorRef){
  }

  ngOnInit(){
    this.viewRef = this.templateRef.createEmbeddedView(null);

    console.log('inserting...')
    this.vref.insert(this.viewRef);
  
    this.cdr.detectChanges();

    console.log('detaching...')
    this.vref.detach();

    console.log('detecting changes...')
    this.viewRef.detectChanges();
  }
}

The most obvious fix to me would be to also clear RefreshTransplantedView flag in addition to reducing transplanted view counter on the parens when the view gets detached.

After looking at the source code a bit more, I think this makes sense. And I think it would be a one line fix editing the detachMovedView() function to mimic the RefreshTransplantedView flag resetting being done in refreshView().

function detachMovedView(declarationContainer: LContainer, lView: LView) {
  ngDevMode && assertLContainer(declarationContainer);
  ngDevMode &&
      assertDefined(
          declarationContainer[MOVED_VIEWS],
          'A projected view should belong to a non-empty projected views collection');
  const movedViews = declarationContainer[MOVED_VIEWS]!;
  const declarationViewIndex = movedViews.indexOf(lView);
  const insertionLContainer = lView[PARENT] as LContainer;
  ngDevMode && assertLContainer(insertionLContainer);

  // If the view was marked for refresh but then detached before it was checked (where the flag
  // would be cleared and the counter decremented), we need to decrement the view counter here
  // instead.
  if (lView[FLAGS] & LViewFlags.RefreshTransplantedView) {
    // lView[FLAGS] &= ~LViewFlags.RefreshTransplantedView;   // <-- ADD THIS LINE -->
    updateTransplantedViewCount(insertionLContainer, -1);
  }

  movedViews.splice(declarationViewIndex, 1);
}

@atscott
Copy link
Contributor

atscott commented Aug 28, 2020

After looking at the source code a bit more, I think this makes sense. And I think it would be a one line fix editing the detachMovedView() function to mimic the RefreshTransplantedView flag resetting being done in refreshView().

Yep, I believe that's the right fix as well! Do you want to send a PR that I can review? Your test can be added to packages/core/test/acceptance/change_detection_transplanted_view_spec.ts

@Achilles1515
Copy link
Author

I'm a total noob when it comes to testing in Angular, and still am somewhat new to Git, but I'll give it a go.

atscott added a commit to atscott/angular that referenced this issue Sep 9, 2020
The `RefreshTransplantedView` flag is used to indicate that the view or one of its children
is transplanted and dirty, so it should still be refreshed as part of change detection.
This flag is set on the transplanted view itself as well setting a
counter on as its parents.
When a transplanted view is detached and still has this flag, it means
it got detached before it was refreshed. This can happen for "backwards
references" or transplanted views that are inserted at a location that
was already checked. In this case, we should decrement the parent
counters _and_ clear the flag on the detached view so it's not seen as
"transplanted" anymore (it is detached and has no parent counters to
adjust).

fixes angular#38619
AndrewKushnir pushed a commit that referenced this issue Sep 10, 2020
The `RefreshTransplantedView` flag is used to indicate that the view or one of its children
is transplanted and dirty, so it should still be refreshed as part of change detection.
This flag is set on the transplanted view itself as well setting a
counter on as its parents.
When a transplanted view is detached and still has this flag, it means
it got detached before it was refreshed. This can happen for "backwards
references" or transplanted views that are inserted at a location that
was already checked. In this case, we should decrement the parent
counters _and_ clear the flag on the detached view so it's not seen as
"transplanted" anymore (it is detached and has no parent counters to
adjust).

fixes #38619

PR Close #38768
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Oct 11, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: core Issues related to the framework runtime core: change detection core: dynamic view creation freq1: low regression Indicates than the issue relates to something that worked in a previous version type: bug/fix
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants