Integration with other reactive systems (mobx case)
See original GitHub issueWhich @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:
- 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. - 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:
- Created 2 years ago
- Reactions:29
- Comments:7 (4 by maintainers)
Top GitHub Comments
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.
Oh I think you are correct, indeed.