infernojs / inferno

:fire: An extremely fast, React-like JavaScript library for building modern user interfaces

Home Page:https://infernojs.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

inferno-mobx observer is incompatible with certain life cycle hooks

Gwenio opened this issue · comments

Preface

The issue comes from the fact that if class Components implement at least one of the getSnapshotBeforeUpdate or the getDerivedStateFromProps life cycle hook then the componentWillMount, componentWillReceiveProps, and componentWillUpdate hooks will be ignored.

Which by the way this behavior is not mentioned in the documentation on class Components.

For inferno-mobx's observer function, it uses the componentWillMount hook to set up the reactive re-rendering of the Component.

The componentWillMount hook is normally called in createClassComponentInstance function from packages/inferno/src/DOM/utils/componentUtil.ts. But if getSnapshotBeforeUpdate or getDerivedStateFromProps are present then componentWillMount is not called.

Observed Behaviour

If a class Component that implements getSnapshotBeforeUpdate or getDerivedStateFromProps and is passed to inferno-mobx's observer function will not update in response to changes in the mobx observables it depends on.

If getSnapshotBeforeUpdate and getDerivedStateFromProps are removed then things work as expected.

Expected Current Behaviour

A class Component passed to inferno-mobx's observer function should update in response to changes in the mobx observables it depends on regardless of which life cycle hooks it does or does not implement.

How to Fix

The best way (in the context of how inferno currently works) to make inferno-mobx's observer function work for class Components implementing getSnapshotBeforeUpdate or getDerivedStateFromProps is to initialize the mobx reaction on the first call of the render function. The componentDidMount could also be used, but would require forcing an update as it is called after the first use of the Component's render method. Initializing the first time render is called avoids this.

There is one part of the componentDidMount created by inferno-mobx that may not transfer well is turning the props and state properties of the Component into mobx atoms. This is technically an unneeded step for making a Component react to changes in mobx observables. It is only needed if the value of those properties will be directly changed by user code. If props is only set by inferno and state is changed with setState (or updated when getDerivedStateFromProps is called) then the component would already be re-rendered when appropriate.

If that aspect of inferno-mobx cannot be moved into an override of render, then to maintain backwards compatibility the observer function will need to check for getSnapshotBeforeUpdate and getDerivedStateFromProps and then override either componentDidMount or render as appropriate.

Hi,

Thanks for reporting this issue. You seem to have good knowledge about mobx, would you like to send PR to fix the issue?

The reason why those lifecycle methods dont get called when implementing the new methods is for react compatibility

The reason why those lifecycle methods dont get called when implementing the new methods is for react compatibility

Should still be mentioned in the documentation.


But back to inferno-mobx. If I am going to submit a PR, I would like to have the direction of it nailed down.

Here is list of things that can be done on inferno-mobx (would each be a separate PR):

  • add new function to inferno-mobx that wires up class components correctly in a way that is independent of new / old lifecycle
  • the existing observer function will use new function internally for class components with new life cycle hook(s)
  • for version 8 of Inferno, depreciate existing observer function (because it would have inconsistent behavior between new and old lifecycle components)
  • to support functional components, add a class component that takes a render function and life cycle hooks in its props
  • fix Provider and inject so they are useful.

On the last point, currently all Provider and inject do is:

  • Provider puts its props in the child context in an object under the key mobxStores
  • inject retrieves values from its context and adds them to the props of the component it is a wrapper for.
  • inject is made into an observer, but this does nothing currently but add overhead as the object it retrieves from is not observable
  • if I understand how context in Inferno work, if the props for Provider change the context is not updated
  • all this means that if the goal is to get a component to update if certain props change then you should currently not use Provider and inject

Let me know which of those changes are of interest.


Here is a pretty much minimal function for instrumenting class Components to react to changes in mobx observables:

import { InfernoNode } from 'inferno'
import { Reaction } from 'mobx'

type RenderReturn = InfernoNode | undefined | void

type Render = (this, properties?, state?, context?) => RenderReturn

type Result<R extends Render = Render> = R & { $: () => void }

function makeObserver<R extends Render>(update: () => void, render: R, name: string): Result<R> {
	const reactor = new Reaction(name, update)
	const track = reactor.track.bind(reactor)
	const observer = function (this, ...parameters: Parameters<typeof render>) {
		let rendered: RenderReturn
		track(() => {
			rendered = render(this, ...parameters)
		})
		return rendered
	} as Result<R>
	observer.$ = reactor.dispose.bind(reactor)
	return observer
}

interface Target {
	readonly displayName?: string
	readonly forceUpdate: (callback?: () => void) => void
	render: Render
	componentWillUnmount?: () => void
}

export function observerPatch<T extends Target, P, C>(
	clazz: (new (p: P, c: C) => T) | (new (p: P) => T) | (new () => T)
): void {
	const proto = clazz.prototype as T
	const base = proto.render
	const name = clazz.name
	proto.render = function (this: T, ...parameters) {
		const update = this.forceUpdate.bind(this, undefined)
		const render = makeObserver(update, base, `${this.displayName || name}.render()`)
		this.render = render
		return render.apply(this, parameters)
	}
	if (proto.componentWillUnmount) {
		const unmount = proto.componentWillUnmount
		proto.componentWillUnmount = function (this: T & { render: Result }) {
			this.render.$()
			this.render = base as Result
			unmount.call(this)
		}
	} else {
		proto.componentWillUnmount = function (this: T & { render: Result }) {
			this.render.$()
			this.render = base as Result
		}
	}
}

A couple of questions about Inferno:

  • am I understanding correctly that render can end up being called after componentWillUnmount if a state change ends up being async (at least if the change is forced)?
  • would it be nicer to use setState to trigger the re-render instead of forceUpdate and if so what should be passed to it?

If the above observerPatch was exported from inferno-mobx it would be used as follows:

import { Component, render } from 'inferno'
import { observerPatch } from 'inferno-mobx'
import { action, observable } from 'mobx'
import i18next from 'i18next'

// class Component for setting the title heading on a single page app
class Title extends Component<{ title: () => string }, Record<string, unknown>> {
  public render({ title }: { title: () => string }) { return title() }
}
observerPatch(Title)

i18next.init(/* options */).then(() => {
  const store = observable({
    page: 'main', // current page id
    t: i18next.getFixedT() // translation function
  })
  i18next.on('languageChanged', action(() => store.t = i18next.getFixedT()))
  render(<Title title={() => store.t(`title.${store.page}`)} />, document.querySelector('h1#title'))

  // render rest of page and hook up store.page to be changed if going to a different 'page'
}).catch(/* handle errors */)

am I understanding correctly that render can end up being called after componentWillUnmount if a state change ends up being async (at least if the change is forced)?

That should not generally happen, because unmounted components are tracked:

// If instance component was removed during its own update do nothing.
if (instance.$UN) {
return;

would it be nicer to use setState to trigger the re-render instead of forceUpdate and if so what should be passed to it?

yeah, using setState ensures the application does not get into infinite loop, empty object would do

What is the status with this issue btw, is it now considered fixed as both PRs were merged?

Closing this issue due to inactivity