question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Integration with other reactive systems (mobx case)

See original GitHub issue

Which @angular/* package(s) are relevant/releated to the feature request?

core

Description

I’m building an Angular / Mobx integration as already existing one looks a bit suboptimal: it runs unnecessary detectChanges in directive’s ngOnInit hook and also calls detectChanges after each change in the underlying observables instead of just marking the view for check and ensuring app tick call is scheduled.

An integration itself consists of a single structural directive which should render provided template in a reactive context, tracking access to all underlying observables and marking its view for check when any of those observables changes.

Here is how the simplified version of the structural *mobx directive could look like:

import {Reaction} from 'mobx';

@Directive({selector: '[mobx]'})
export class MobxDirective implements OnInit, OnDestroy {

  protected rerenderReaction: Reaction;
  protected view: EmbeddedViewRef<any>;

  constructor(
    protected templateRef: TemplateRef<any>,
    protected viewContainer: ViewContainerRef
  ) {}

  ngOnInit() {
    // `Reaction` represents a reactive context.
    this.rerenderReaction = new Reaction(
      'some reaction name',
      // `Reaction` will call this callback when reactive context is invalidated which happens when value of any tracked observable changes.
      () => this.scheduleRerender()
    );

    // `track` method will immediately execute provided callback and will collect all observables that were accessed during its run.
    this.rerenderReaction.track(() => this.renderTemplate());
  }

  ngOnDestroy() {
    this.rerenderReaction.dispose();
  }

  renderTemplate() {
    this.view = this.viewContainer.createEmbeddedView(this.templateRef);
  }

  scheduleRerender() {
    this.view.markForCheck();
    // Also we'll have to ensure `AppRef.tick()` call is scheduled and schedule it manually if it's not
  }
}

But there are a few problems with this implementation:

  1. Under the hood viewContainer.createEmbeddedView(this.templateRef) actually calls only root template function and doesn’t call render functions of any nested templates which may be rendered by nested structural directives.
  2. It calls root render function in “create” mode (RenderFlags.Create), but we’re interested in “update” mode (RenderFlags.Update) as that’s where all observables will be accessed.

Here is an example of simple reactive component which I hope will clarify what I mean:

import {makeObservable, observable} from 'mobx';

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExampleComponent {
  @observable observable1 = true;
  @observable observable2 = true;
  @observable observable3 = true;
  @observable observable4 = true;

  constructor() {
    makeObservable(this);
  }
}

Its template:

<div *mobx>
  {{ observable1 }}
  <div *ngIf="observable2">
    {{ observable3 }}
  </div>
  {{ observable4 }}
</div>

And compiled template functions with comments that should clarify the situation:

function ExampleComponent_Root_Template(rf, ctx) {
  if (rf & 1) {
    // "create" phase (will be executed during `viewContainer.createEmbeddedView(this.templateRef)` in `*mobx` directive)
    // But we're not interested in it as in this phase `ctx` is not ready yet and doesn't contain any observables.
    ɵɵelementStart(0, "div");
    ɵɵtext(1);
    // This line doesn't actually call `ExampleComponent_NestedNgIf_Template` but schedules it to be called
    // sometime later during change detection cycle
    ɵɵtemplate(2, ExampleComponent_NestedNgIf_Template, 2, 1, "div", 1);
    ɵɵtext(3);
    ɵɵelementEnd();
  }

  if (rf & 2) {
    // "update" phase - here is what we're looking for!
    // We need to run this call in reactive context (wrap it with `reaction.track(...)`) but it will be executed only
    // after `*mobx`s `ngOnInit`, sometime between `DoCheck` and `AfterViewInit` hooks and I couldn't find
    // an easy way to do this.
    const ctx_r0 = ɵɵnextContext();
    ɵɵadvance(1);
    // `observable1` is accessed here and reactive context starts tracking it
    ɵɵtextInterpolate1(" ", ctx_r0.observable1, " ");
    ɵɵadvance(1);
    // ...also tracking `observable2`
    ɵɵproperty("ngIf", ctx_r0.observable2);
    ɵɵadvance(1);
    // ...and `observable4`
    ɵɵtextInterpolate1(" ", ctx_r0.observable4, "\n");
    // And here is another problem: `observable3` is accessed in `ExampleComponent_NestedNgIf_Template` which
    // will be called later during CD cycle so we also have to wrap it with `reaction.track(...)` somehow...
  }
}

function ExampleComponent_NestedNgIf_Template(rf, ctx) {
  if (rf & 1) {
    // "create" phase (we're not interested in it)
    ɵɵelementStart(0, "div");
    ɵɵtext(1);
    ɵɵelementEnd();
  }

  if (rf & 2) {
    // "update" phase (will be executed sometime between `*mobx`s `DoCheck` and `AfterViewInit` hooks)
    const ctx_r1 = ɵɵnextContext(2);
    ɵɵadvance(1);
    // We need to track `observable3` here but there is no easy way to wrap the call to 
    // `ExampleComponent_NestedNgIf_Template` into a `reaction.track()`
    ɵɵtextInterpolate1(" ", ctx_r1.observable3, " ");
  }
}

I was able to create an uber-hacky solution, but, of course, I don’t like it at all:

import {
  // ...
  ɵRenderFlags
} from '@angular/core';
import {Reaction} from 'mobx';

// Hack: should be imported from `@angular/core` but it's part of private API
type ComponentTemplate = (renderFlags: ɵRenderFlags) => void;

const originalToProcessedTemplatesMap = new Map<ComponentTemplate, ComponentTemplate>();
const processedTemplates = new Set<ComponentTemplate>();
const activeDirectivesStack: MobxDirective[] = [];

@Directive({selector: '[mobx]'})
export class MobxDirective implements OnInit, DoCheck, AfterViewInit, OnDestroy {

  protected rerenderReaction: Reaction;
  protected view: EmbeddedViewRef<void>;
  protected mainTemplate: ComponentTemplate;
  protected inTrackingFunction = false;

  constructor(
    protected templateRef: TemplateRef<void>,
    protected viewContainer: ViewContainerRef
  ) {}

  ngOnInit() {
    this.view = this.viewContainer.createEmbeddedView(this.templateRef);
    this.rerenderReaction = new Reaction(`reaction name`, () => this.scheduleRerender());
    this.processViewTemplates();
  }

  ngDoCheck() {
    activeDirectivesStack.push(this);
  }

  ngAfterViewInit() {
    activeDirectivesStack.pop();
  }

  ngOnDestroy() {
    this.rerenderReaction.dispose();
  }

  private processViewTemplates(
    // Hack: using non-public API
    tView = (this.view as any)._lView[1]
  ) {
    this.processTViewTemplate(tView);

    const tViewData = tView.data;
    let i = tViewData.length;

    while (i--) {
      const dataItem = tViewData[i];
      if (dataItem && dataItem.tViews) {
        this.processViewTemplates(dataItem.tViews);
      }
    }
  }

  private processTViewTemplate(tView: any) {
    // Hack: `TView` type is part of private API
    const {template} = tView;

    if (processedTemplates.has(template)) {
      return;
    }

    if (!originalToProcessedTemplatesMap.has(template)) {
      if (!this.mainTemplate) {
        this.mainTemplate = template;
      }

      const processedTemplate = function(mode: ɵRenderFlags) {
        const activeDirective = activeDirectivesStack[activeDirectivesStack.length - 1];

        if (!activeDirective || mode === ɵRenderFlags.Create) {
          return template.apply(this, arguments);
        }

        const thisObj = this;
        const args = arguments;
        const isMainTemplate = template === activeDirective.mainTemplate;

        if (!activeDirective.inTrackingFunction) {
          activeDirective.trackObservables(
            () => {
              activeDirective.inTrackingFunction = true;
              template.apply(thisObj, args);
              activeDirective.inTrackingFunction = false;
            },
            // Starting tracking from scratch if it's a main template
            isMainTemplate
          );
        } else {
          // We're already in tracking function so just calling the template function
          template.apply(thisObj, args);
        }

        // Hack: need to search for unprocessed templates after every template call
        // as I couldn't find any way to process them all at once
        // Also adds extra runtime overhead which is really bad
        activeDirective.processViewTemplates();
      };

      originalToProcessedTemplatesMap.set(template, processedTemplate);
      processedTemplates.add(processedTemplate);
    }

    // Hack: replacing a template with its new version that supports tracking observables
    tView.template = originalToProcessedTemplatesMap.get(template);
  }

  private trackObservables(fn: () => void, resetReaction: boolean) {
    if (resetReaction) {
      this.rerenderReaction.track(fn);
    } else {
      this.rerenderReaction.addTracking(fn);
    }
  }

  private scheduleRerender() {
    this.view.markForCheck();
  }
}

Proposed solution

So, basically, what I need is some API that would allow to wrap calls of compiled template functions, but I’m not sure how it could look like.

Another possible solution I can think of is introducing some new hook for components/directives that would allow them to run their part of change detection cycle. In this case *mobx directive could look like this:

import {Reaction} from 'mobx';

@Directive({selector: '[mobx]'})
export class MobxDirective implements OnInit, RunCheck, OnDestroy {

  protected rerenderReaction: Reaction;
  protected view: EmbeddedViewRef<any>;

  constructor(
    protected templateRef: TemplateRef<any>,
    protected viewContainer: ViewContainerRef
  ) {}

  ngOnInit() {
    this.renderTemplate();
    this.rerenderReaction = new Reaction('some reaction name', () => this.scheduleRerender());
  }

  // A new hook for components/directives that allows them to run their part of CD check manually.
  // Responsibility of the component is to call provided `runCheck` function synchronously.
  ngRunCheck(runCheck: () => void) {
    this.rerenderReaction.track(runCheck);
  }

  ngOnDestroy() {
    this.rerenderReaction.dispose();
  }

  renderTemplate() {
    this.view = this.viewContainer.createEmbeddedView(this.templateRef);
  }

  scheduleRerender() {
    this.view.markForCheck();
    // Also we'll have to ensure `AppRef.tick()` call is scheduled and schedule it manually if it's not
  }
}

Alternatives considered

Alternative solution already exists (mobx-angular), but as I stated in Description it’s not optimal and can be significantly improved.

Another problems

There is also the following problem which can be solved by providing a way to wrap/decorate automatically generated event listeners in component templates.

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:29
  • Comments:7 (4 by maintainers)

github_iconTop GitHub Comments

15reactions
angular-robot[bot]commented, Nov 19, 2021

This feature request is now candidate for our backlog! In the next phase, the community has 60 days to upvote. If the request receives more than 20 upvotes, we’ll move it to our consideration list.

You can find more details about the feature request process in our documentation.

0reactions
JoostKcommented, Sep 18, 2022

Oh I think you are correct, indeed.

Read more comments on GitHub >

github_iconTop Results From Across the Web

React integration
While MobX works independently from React, they are most commonly used together. In The gist of MobX you have already seen the most...
Read more >
Testable state management using React Native with MobX
This article will go through how to manage the state of your React Native application and test it. We will be using the...
Read more >
How to Test React and MobX with Jest
Learn how to get started with unit testing for a React and MobX application using Enzyme and Jest, including continuous testing.
Read more >
Managing the State of React Apps with MobX
As mentioned, MobX is another state management library available for React apps. This alternative uses a more reactive process, and it is ...
Read more >
React and MobX-state-tree
Holding user preferences, site-wide selections, and having a reactive user ... However, there is one other aspect of MST that I would like...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found