Search code examples
javascriptangularangular-changedetection

ngOnChanges does not trigger the second time it's called only


We've built a library at work called ngx-observable-lifecycle.

It's basically hooking into all the native hooks of Angular components (ngOnInit, ngOnChanges, etc) and then gives an object where each key is the name of a given hook, and the value is an observable of when that hook is called.

Think of it as a small wrapper utility to be able to work in a more reactive way without having to create manually Subjects and push when a hook happens.

It's all working really well, except in one very, very, very specific case: ngOnChanges.

We'be built a simple demo where a parent component defines a counter, clicking on a button increments it, and that counter is passed to an input of a child component. Nothing fancy here.

From the child component, we then use the library to hook in ngOnChanges and subscribe to the observable.

  • When the app is launched, the initial counter value is set and the subscribe notifies us that we received it as expected
  • When we increment, ngOnChanges does not happen, which is the main issue here. I can see doCheck, afterContentChecked, afterViewChecked but not ngOnChanges
  • From there on, we can increment as many times as we want, it'll work as expected

I cannot wrap my head around this, why would the first one work, not the second one, and all the other work fine after? In the library as far as I can tell, we don't have a skip or anything similar.

The library is on Angular 13 but it's very light. I've tried upgrading to Angular 15, same deal. I've created a Stackblitz repo which runs one of the latest versions (17.1.2), same thing.

So the good news is, it's easily reproducible. But I don't understand the root cause.

You can find the reproduction I've made in this Stackblitz.

  • Open the browser console (not Stackblitz one), there's more things displayed for some reason
  • Notice that when the app is launched, you'll see a line {changes: {…}} which is the debug we're after. It's log triggered by our ngOnChanges observable
  • Click on "increment input value" and notice that you won't get the {changes: {…}} logged this time
  • Click again on the increment button, and from now on, it'll always work. BUT, if you look at the changes logged, it's the changes from the last change detection cycle, it's always one value late

The code structure is simple in the Stackblitz:

  • One main component providing the incremented value as an input to the child component
  • One child component getting the value as an input
  • One file containing the whole code of the library (it's a tiny lib, <150 lines in total so everything is in this file)

I have ruled out the classic issue of an input passing an object with the same reference instead of passing a new object reference, as we're just passing an increment input here. From the view, you'll be able to tell what's the current value (and it's the correct one in the view) but in the logs, you'll see after the broken bit it's always late by one.

Oh there's one more really important detail which confuses me even harder. In the child component, if you uncomment the normal ngOnChanges hook (which is empty), everything works as expected.

I have no idea how this could have an effect when the only reference we've got to the original hook is this:

    const originalHook = proto[hook];

    proto[hook] = function (...args: any[]) {
      originalHook?.call(this, ...args);

and it looks fairly safe to me. We save the original hook, we override it with our own function, which starts by calling the existing one first before applying custom logic IF there's an original hook.

The main chunk of the code is the following:

function getSubjectForHook(
  componentInstance: PatchedComponentInstance,
  hook: LifecycleHookKey
): Subject<void> {
  if (!componentInstance[hookSubject]) {
    componentInstance[hookSubject] = {};
  }

  if (!componentInstance[hookSubject][hook]) {
    componentInstance[hookSubject][hook] = new Subject<void>();
  }

  const proto = componentInstance.constructor.prototype;
  if (!proto[hooksPatched]) {
    proto[hooksPatched] = {};
  }

  if (!proto[hooksPatched][hook]) {
    const originalHook = proto[hook];

    proto[hook] = function (...args: any[]) {
      originalHook?.call(this, ...args);

      if (hook === 'ngOnChanges') {
        this[hookSubject]?.[hook]?.next(args[0]);
      } else {
        this[hookSubject]?.[hook]?.next();
      }
    };

    const originalOnDestroy = proto.ngOnDestroy;
    proto.ngOnDestroy = function (this: PatchedComponentInstance<typeof hook>) {
      originalOnDestroy?.call(this);
      this[hookSubject]?.[hook]?.complete();
      delete this[hookSubject]?.[hook];
    };

    proto[hooksPatched][hook] = true;
  }

  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  return componentInstance[hookSubject][hook]!;
}

With this, for a given component and a given hook key (ngOnInit, ngOnChanges, etc), we build a cache layer to avoid creating it multiple times if requested again. We save the original hook if it's defined in the component, and then we monkey patch the hook to call the original + our code to push the value in an observable.

Here's a reminder of the Stackblitz. Thanks for any help.

EDIT 1: Here's how it looks like

Demo of the issue

EDIT 2: Even weirder, I made a minimal repro on Stackblitz to see if it'd work correctly and it does work 100% as expected in the minimal repro but I'm not able to pin down the diff.

It's as simple as this

function patch(component: any) {
  const original = component.constructor.prototype.ngOnChanges;

  component.constructor.prototype.ngOnChanges = (changes: any) => {
    original?.(changes);
    console.log(`changes!`, JSON.stringify(changes, null, 2));
  };
}

@Component({
  selector: 'app-child',
  templateUrl: './child.component.html',
  styleUrls: ['./child.component.css'],
  standalone: true,
})
export class ChildComponent {
  @Input() value: any;

  constructor() {
    patch(this);
  }

  // in this minimal repro which very much looks like the implementation
  // I've got in the full one, it works without having to have the ngOnChanges...
  // ngOnChanges() {
  //   console.log('original')
  // }
}

But... I feel like that's exactly what I have in the other stackblitz as well.


Solution

  • I work a bit on the angular framework so I can give you some insights.

    ngOnChanges isn't just a regular method on a component.

    When the angular compiler detects that a component uses this hooks, it decorates a component with support for the ngOnChanges lifecycle hook.

    The generated JS looks a bit like following:

    static ɵcmp = defineComponent({
       ...
       inputs: {name: 'publicName'},
       features: [NgOnChangesFeature]
     });
    

    Without entering into details, what happens without it, is that the hooks gets called before the setInput.

    This is why, on the first clic you get no changes (the value hasn't been incremented yet). On the 2nd click you get a change, but the value is the one before.

    So there is always a difference between your onchange and the value you see on the template.

    You can reproduce this easily by adding a break-point in ng_onchanges_feature.ts on both rememberChangeHistoryAndInvokeOnChangesHook and ngOnChangesSetInput.

    To add break points to the framework files, make sure to enable sourcemaps in your angular.json:

              "sourceMap": {
                "scripts": true,
                "vendor": true
              }
    

    Source code reference.