From 8b6750fa4143ea9d7283e157daa5ecf719361246 Mon Sep 17 00:00:00 2001 From: James Adam Armstrong Date: Thu, 30 Dec 2021 13:34:02 -0600 Subject: [PATCH] Added observerPatch to inferno-mobx (#1586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sampo Kivistö --- packages/inferno-mobx/README.md | 301 ++++++++++++ .../__tests__/observerPatch.spec.jsx | 464 ++++++++++++++++++ packages/inferno-mobx/src/index.ts | 3 +- packages/inferno-mobx/src/observerPatch.ts | 73 +++ 4 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 packages/inferno-mobx/__tests__/observerPatch.spec.jsx create mode 100644 packages/inferno-mobx/src/observerPatch.ts diff --git a/packages/inferno-mobx/README.md b/packages/inferno-mobx/README.md index d9efd2ab0..fe65c17ee 100644 --- a/packages/inferno-mobx/README.md +++ b/packages/inferno-mobx/README.md @@ -6,6 +6,8 @@ The module is compatible with Inferno v1+, for older versions use [mobx-inferno] This package provides the bindings for MobX and Inferno. Exports `observer` and `inject` decorators, a `Provider` and some development utilities. +*New*: exports `observerPatch`, a function to turn Component classes into MobX observers. +`observerPatch` is implemented in a better manner, but is separate as it would break compatibility in some cases. ## Install @@ -21,6 +23,160 @@ npm install --save mobx ## Example +Pass a class Component to `observerPatch` to have in automatically re-render if MobX observables read by `render` are modified. + +```typescript +// MyComponent.ts (also works with plain JavaScript) +import { Component } from 'inferno'; +import { observerPatch } from 'inferno-mobx'; + +interface CountStore { + readonly count: number +} + +export class MyComponentA extends Component<{ countStore: CountStore }> { + render({ countStore }: { countStore: CountStore }) { + return (

Current Count: {countStore.count.toString()}

); + } +} + +observerPatch(MyComponentA); + +// Or you can use functions that read from stores instead of the stores +export class MyComponentB extends Component<{ count: () => number }> { + render({ count }: { count: () => number }) { + return (

Current Count: {count().toString()}

); + } +} + +observerPatch(MyComponentB); + +// However, passing a value from an observable directly as a property will NOT work! +export class MyComponentC extends Component<{ count: number }> { + render({ count }: { count: number }) { + return (

Current Count: {count.toString()}

); + } +} + +observerPatch(MyComponentC); + +function MyComponentF({ count }: { count: () => number }) { + return (

Current Count: {count().toString()}

); +} + +// Detection does NOT cross component boundaries. So this does NOT work: +export class MyComponentD extends Component<{ countStore: CountStore }> { + render({ countStore }: { countStore: CountStore }) { + return (
+ countStore.count} /> +
); + } +} + +observerPatch(MyComponentD); + +// You can use simple functional components as functions: +export class MyComponentE extends Component<{ countStore: CountStore }> { + render({ countStore }: { countStore: CountStore }) { + // But keep in mind that the whole component will re-render. + // + return (
{ + MyComponentF({count: () => countStore.count}) + }
); + } +} + +observerPatch(MyComponentE); + +// Only Components that depend on MobX observables need to be observers. +export class MyComponentG extends Component<{ countStore: CountStore }> { + render({ countStore }: { countStore: CountStore }) { + return (
+ // MyComponentB is an observer and will re-render when countStore.count changes. + countStore.count} /> +
); + } +} + +// observerPatch(MyComponentG) is not needed and would add overhead for no reason. + +// If you want both an observer and a non observer versions of a component, +// then you can just extend the non observer and patch the sub class. +export class MyComponentH extends Component<{ count: () => number }> { // non observer base class + render({ count }: { count: () => number }) { + return (

Current Count: {count().toString()}

); + } +} + +export class MyComponentI extends MyComponentH {} // sub class indended to be an observer +observerPatch(MyComponentI); // make the sub class an observer + +// DO NOT extend from Components that are obsevers. +// If you do have reason to extend a Component class that will be an observer, +// see above on how to easily have both an obsever and a non-observer version. +export class MyComponentJ extends MyComponentA { + render(properties: { countStore: CountStore }) { + return (
+ {super.render(properties)} +
); + } +} + +// Even if you do not call observerPatch on the sub class, extending +// from observers can create problems. +observerPatch(MyComponentJ); + +// DO NOT call observerPatch more than once on a clase. +export class MyComponentK extends Component<{ countStore: CountStore }> { + render({ countStore }: { countStore: CountStore }) { + return (

Current Count: {countStore.count.toString()}

); + } +} + +observerPatch(MyComponentK); +observerPatch(MyComponentK); // NEVER call more than once per class! +``` + +```typescript +// index.ts +import { + MyComponentA, + MyComponentB, + MyComponentC, + MyComponentD, + MyComponentE, + MyComponentG, + MyComponentH, + MyComponentI, + MyComponentJ, + MyComponentK +} from './MyComponent.ts'; +import { render } from 'inferno'; +import { action, observable } from 'mobx'; + +const store = observable({ count: 0 }); + +render(
+ + store.count} /> + // This component WILL NOT detect when count changes! + // This component WILL NOT detect when count changes! + + + store.count} /> // Not an observer so no updating when count changes. + store.count} /> // Is an observer so it will update. + // Works... BUT! when it unmounts there will be an error! + // Works... BUT! when it unmounts there will be an error! + +
, document.getElementById('components')); +``` + +NOTE: `observerPatch` installs a `componentWillUnmount` hook to dispose of the MobX Reaction. +It will then call the `componentWillUnmount` from the class's prototype. +If you dynamically add a `componentWillUnmount` to a class you pass to `observerPatch`, be sure it calls the hook installed by `observerPatch`. + +## Legacy Example + You can inject props using the following syntax ( This example requires, babel decorators-legacy plugin ) ```javascript @@ -81,3 +237,148 @@ render( , document.getElementById('root')); ``` + +## Migrating from observer to observerPatch + +The `observerPatch` was added because the way `observer` was implemented cannot work on class Components that implement either `getSnapshotBeforeUpdate` or `getDerivedStateFromProps`. +Having differences in implementation that can matter to user code based on which lifecycle hooks are present is not good design. +Changing how `observer` is implemented in ways that could break existing user code is not worth the cost. +Furthermore, `observerPatch` provides better performance in the resulting class than `observer`. + +The differences to be aware of when switching from `observer` to `observerPatch` are: + +1. `observerPatch` is not implemented to be used as a decorator +2. `observerPatch` returns `void` instead of returning the class it was applied to +3. `observerPatch` will not have the observer call `this.componentWillReact()` if such a member exists +4. `observerPatch` does not add a `shouldComponentUpdate` hook to classes that do not have one +5. `observerPatch` will not catch exceptions thrown by `render` and forward them to `errorsReporter` +6. `observerPatch` ignores `useStaticRendering(true)` +7. `observerPatch` will not emit events through `renderReporter` that list how long `render` took +8. `observerPatch` does not make `this.props` nor `this.state` observable +9. `observerPatch` does not set `isMobXReactObserver = true` as a static class member +10. `observerPatch` is only for class Components, functional Components are not supported + +Points 1 and 2 are a simple change to call `observerPatch` after the class is defined and removing `observer`. + +To replicate the behavior of `observer` for point 3, call `this.componentWillReact()` at the start of your `componentWillMount` hook. +Or if your Component does not have a `componentWillMount`, rename `componentWillReact` to `componentWillMount`. + +For point 4, you can implement your own `shouldComponentUpdate` hook is you want to prevent needless re-renders. +The `shouldComponentUpdate` does not affect re-renders triggered by MobX obervables being modified. +So it exists for when new properties are set or `this.setState` is used. + +Errors sent to `errorsReporter` as mentioned in point 5 could then be sent to a custom handler provided to `onError`. +For exceptions occuring in your render method, catch them in the method and forward to your handler. +This cuts out extra intermediate steps. +Otherwise, they will go to the MobX global Reaction error handler set with `onReactionError`. +Which is where they may have been going anyways. +There was no unit test checking this behavior. + +For point 6, all your components other than those passed to `observer` already ignore it. +If there is demand, generating warning messages might return `useStaticRendering(true)` is called. +But it would only be in development builds of Inferno and would not prevent methods from running. + +For point 7, you can do it better that `observer` did. +Also, it only did so if you toggled it on by calling `trackComponents()`. + +Point 8 means that if you directly set `this.props` or `this.state` to a new value it will not trigget a re-render. +You should not be directly setting `this.props`. Let Inferno update Component properties. +You should not be directly setting `this.state` outside of the `componentWillMount` and `componentWillReceiveProps` hooks. +The component will always have `render` called after `componentWillMount`. +The component will have `render` called after `componentWillReceiveProps` unless `shouldComponentUpdate` returns `false`. +Any time Inferno updates `this.props` or `this.setState` is used to update state, the component will re-render unless `shouldComponentUpdate` returns `false`. + +For point 9, `observerPatch` instead sets `isMobXInfernoObserver = true` as a static member of the class. +But it only does do in development builds as it is intended for use internally for sanity checks. +The issues it is used to spot and warn about should be fixed before reaching production. + +Finally for point 10, the way `observer` worked with functional was to wrap them in a class Component. +It likely was not what you wanted. +It ignored any default hooks and being wrapped in class Component you could not add new ones. +And if your functional Component was only ever used as an observer, then directly making it a class Component would be better. + +If this seems intimidating, know that nearly all unit tests for `observer` worked as they were after replacing `observer` with `observerPatch`. +Outside of needing to switch functional Components to class Components, that is. + +## Using observerPatch with Provider and inject + +The `observerPatch` function can be used with `Provider` and `inject` just like with `observer`. +However, using `inject` as a decorator along side `observerPatch` is not supported. + +```typescript +// MyInjected.ts +import { Component } from 'inferno'; +import { observerPatch, inject } from 'inferno-mobx'; + +interface CountStore { + readonly count: number +} + +// The class produced by inject will require the injected properties if required by the base class +class MyComponentA extends Component<{ countStore?: CountStore }> { + render({ countStore }: { countStore?: CountStore }) { + // If only the injected version will be used, casting is safe as an exception is thrown + // if the property is unavailable + const count = (countStore as CountStore).count.toString(); // unsafe if MyComponentA was exported + return (

Current Count: {count}

); + } +} + +// Recommended order +observerPatch(MyComponentA); +export const MyInjectedA = inject('countStore')(MyComponentA); + +class MyComponentB extends Component<{ countStore?: CountStore }> { + render({ countStore }: { countStore?: CountStore }) { + const count = (countStore as CountStore).count.toString(); + return (

Current Count: {count}

); + } +} + +// The order of inject and observerPatch does not matter. +export const MyInjectedB = inject('countStore')(MyComponentB); +observerPatch(MyComponentB); // Works, but this order is more prone to the mistake shown below + +class MyComponentC extends Component<{ countStore?: CountStore }> { + render({ countStore }: { countStore?: CountStore }) { + const count = (countStore as CountStore).count.toString(); + return (

Current Count: {count}

); + } +} + +// Be sure to use observerPatch on the class, not the injected class. +// A warning message is output if you do this. +export const MyInjectedC = inject('countStore')(MyComponentC); +observerPatch(MyInjectedC); // WRONG! Should be: observerPatch(MyComponentC); +//Having observerPatch before inject lets tools detect this issue. +``` + +```typescript +// index.ts +import { + MyInjectedA, + MyInjectedB, + MyInjectedC +} from './MyInjected.ts'; +import { render } from 'inferno'; +import { Provider } from 'inferno-mobx'; +import { action, observable } from 'mobx'; + +const store = observable({ count: 0 }); + +const store2 = observable({ count: 0 }); + +// NOTE: Do not use Provider and inject for trivial cases like this in real code. +render(
+ + + + // This one will not update as MyComponentC was not made into an observer. + // Will not update as direct properties override injection + + +
, document.getElementById('root')); +``` + +IMPORTANT: The values injected are the ones available to `Provider` when it is first mounted. +So `Provider` and `inject` are only useful for properties that will NEVER change. diff --git a/packages/inferno-mobx/__tests__/observerPatch.spec.jsx b/packages/inferno-mobx/__tests__/observerPatch.spec.jsx new file mode 100644 index 000000000..0d29e515f --- /dev/null +++ b/packages/inferno-mobx/__tests__/observerPatch.spec.jsx @@ -0,0 +1,464 @@ +import { Component, render } from 'inferno'; +import * as mobx from 'mobx'; +import { inject, observer, observerPatch } from 'inferno-mobx'; +import { createClass } from 'inferno-create-class'; +import { warning } from 'inferno-shared'; + +const getDNode = (obj, prop) => obj.$mobx.values[prop]; + +describe('Mobx Observer Patch', () => { + let container; + + beforeEach(function () { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(function () { + render(null, container); + container.innerHTML = ''; + document.body.removeChild(container); + }); + + it('nestedRendering', () => { + const store = mobx.observable({ + todos: [ + { + title: 'a', + completed: false + } + ] + }); + + let todoItemRenderings = 0; + class TodoItem extends Component { + render({ todo }) { + todoItemRenderings++; + return
  • |{todo.title}
  • ; + } + shouldComponentUpdate({ todo: { title } }) { + return title !== this.props.todo.title; + } + } + + observerPatch(TodoItem); + + let todoListRenderings = 0; + let todoListWillReactCount = 0; + const TodoList = createClass({ + componentWillReact() { + todoListWillReactCount++; + }, + render() { + todoListRenderings++; + const todos = store.todos; + return ( +
    + {todos.length} + {todos.map((todo, idx) => ( + + ))} +
    + ); + } + }); + + observerPatch(TodoList); + + render(, container); + expect(todoListRenderings).toEqual(1); //, 'should have rendered list once'); + expect(todoListWillReactCount).toEqual(0); //, 'should never call componentWillReact') + expect(container.querySelectorAll('li').length).toEqual(1); + expect(container.querySelector('li').textContent).toEqual('|a'); + + expect(todoItemRenderings).toEqual(1); // 'item1 should render once' + + expect(getDNode(store, 'todos').observers.length).toBe(1); + expect(getDNode(store.todos[0], 'title').observers.length).toBe(1); + + store.todos[0].title += 'a'; + + expect(todoListRenderings).toEqual(1); //, 'should have rendered list once'); + expect(todoListWillReactCount).toEqual(0); //, 'should never call componentWillReact') + expect(todoItemRenderings).toEqual(2); //, 'item1 should have rendered twice'); + expect(getDNode(store, 'todos').observers.length).toBe(1); //, 'observers count shouldn\'t change'); + expect(getDNode(store.todos[0], 'title').observers.length).toBe(1); //, 'title observers should not have increased'); + + store.todos.push({ + title: 'b', + completed: true + }); + + expect(container.querySelectorAll('li').length).toBe(2); //, 'list should two items in in the list'); + const expectedOutput = []; + const nodes = container.querySelectorAll('li'); + + for (let i = 0; i < nodes.length; i++) { + expectedOutput.push(nodes[i].textContent); + } + expect(expectedOutput).toEqual(['|aa', '|b']); + + expect(todoListRenderings).toBe(2); //'should have rendered list twice'); + expect(todoListWillReactCount).toBe(0); //, 'should never call componentWillReact') + expect(todoItemRenderings).toBe(3); //, 'item2 should have rendered as well'); + expect(getDNode(store.todos[1], 'title').observers.length).toBe(1); //, 'title observers should have increased'); + expect(getDNode(store.todos[1], 'completed').observers.length).toBe(0); //, 'completed observers should not have increased'); + + const oldTodo = store.todos.pop(); + + expect(todoListRenderings).toBe(3); //, 'should have rendered list another time'); + expect(todoListWillReactCount).toBe(0); //, 'should never call componentWillReact') + expect(todoItemRenderings).toBe(3); //, 'item1 should not have rerendered'); + expect(container.querySelectorAll('li').length).toBe(1); //, 'list should have only on item in list now'); + expect(getDNode(oldTodo, 'title').observers.length).toBe(0); //, 'title observers should have decreased'); + expect(getDNode(oldTodo, 'completed').observers.length).toBe(0); //, 'completed observers should not have decreased'); + }); + + it('keep views alive', () => { + let yCalcCount = 0; + const data = mobx.observable({ + x: 3, + get y() { + yCalcCount++; + return this.x * 2; + }, + z: 'hi' + }); + + class TestComponent extends Component { + render() { + return ( +
    + {data.z} + {data.y} +
    + ); + } + } + observerPatch(TestComponent); + + render(, container); + expect(yCalcCount).toBe(1); + expect(container.textContent).toBe('hi6'); + + data.z = 'hello'; + // test: rerender should not need a recomputation of data.y because the subscription is kept alive + + expect(yCalcCount).toBe(1); + + expect(container.textContent).toBe('hello6'); + expect(yCalcCount).toBe(1); + + expect(getDNode(data, 'y').observers.length).toBe(1); + + render(
    , container); + + expect(getDNode(data, 'y').observers.length).toBe(0); + }); + + it('patched render is run first', (done) => { + let origRenderMethod; + const Comp = createClass({ + render() { + // ugly check, but proofs that observer.willmount has run + // We cannot use function.prototype.name here like in react-redux tests because it is not supported in Edge/IE + expect(this.render).not.toBe(origRenderMethod); + return null; + } + }); + origRenderMethod = Comp.prototype.render; + + observerPatch(Comp); + render(, container); + done(); + }); + + it('issue 12', function () { + const data = mobx.observable({ + selected: 'coffee', + items: [ + { + name: 'coffee' + }, + { + name: 'tea' + } + ] + }); + + /** Row Class */ + class Row extends Component { + constructor(props) { + super(props); + } + + render() { + return ( + + {this.props.item.name} + {data.selected === this.props.item.name ? '!' : ''} + + ); + } + } + + /** table stateles component */ + class Table extends Component { + render() { + return ( +
    + {data.items.map((item) => ( + + ))} +
    + ); + } + } + observerPatch(Table); + + render(, container); + + expect(container.querySelector('div').textContent).toBe('coffee!tea'); + + mobx.runInAction(() => { + data.items[1].name = 'boe'; + data.items.splice(0, 2, { name: 'soup' }); + data.selected = 'tea'; + }); + + expect(container.querySelector('div').textContent).toBe('soup'); + }); + + it('component should not be inject', function (done) { + const msg = []; + const baseWarn = console.error; + console.error = (m) => msg.push(m); + + const Foo = inject('foo')( + createClass({ + render() { + return ( +
    + context: + {this.props.foo} +
    + ); + } + }) + ); + observerPatch(Foo); + + expect(msg.length).toBe(1); + console.error = baseWarn; + done(); + }); + + it('component should not be observer', function (done) { + const msg = []; + const baseWarn = console.error; + console.error = (m) => msg.push(m); + + const Foo = observer( + createClass({ + render() { + return ( +
    + context: + {this.props.foo} +
    + ); + } + }) + ); + observerPatch(Foo); + + expect(msg.length).toBe(1); + console.error = baseWarn; + done(); + }); + + it('component should not be already be patched', function (done) { + const msg = []; + const baseWarn = console.error; + console.error = (m) => msg.push(m); + + const Foo = createClass({ + render() { + return ( +
    + context: + {this.props.foo} +
    + ); + } + }); + observerPatch(Foo); + observerPatch(Foo); + + expect(msg.length).toBe(1); + console.error = baseWarn; + done(); + }); + + it('observer component can be injected', (done) => { + const msg = []; + const baseWarn = console.error; + console.error = (m) => msg.push(m); + + const fooA = createClass({ + render: () => null + }); + observerPatch(fooA); + inject('foo')(fooA); + + // N.B, the injected component will be observer since mobx-react 4.0! + const fooB = createClass({ + render: () => null + }); + observerPatch(fooB); + inject(() => {})(fooB); + + expect(msg.length).toBe(0); + console.error = baseWarn; + done(); + }); + + it('should do warn when a patching a class extended from a patched class', (done) => { + const msg = []; + const baseWarn = console.error; + console.error = (m) => msg.push(m); + + class fooA extends Component { + render() { + return

    Foo A

    ; + } + } + observerPatch(fooA); + + class fooB extends fooA { + render() { + return

    Foo B

    ; + } + } + observerPatch(fooB); + + expect(msg.length).toBe(1); + console.error = baseWarn; + done(); + }); + + it('should render component even if setState called with exactly the same props', function (done) { + let renderCount = 0; + const Component = createClass({ + onClick() { + this.setState({}); + }, + render() { + renderCount++; + return
    ; + } + }); + observerPatch(Component); + render(, container); + + expect(renderCount).toBe(1); //'renderCount === 1'); + container.querySelector('#clickableDiv').click(); + expect(renderCount).toBe(2); // 'renderCount === 2'); + container.querySelector('#clickableDiv').click(); + expect(renderCount).toBe(3); //'renderCount === 3'); + done(); + }); + + // it('it rerenders correctly if some props are non-observables - 1', done => { + // let renderCount = 0; + // let odata = observable({ x: 1 }) + // let data = { y : 1 } + // + // @observer class Com extends Component { + // @computed get computed () { + // // n.b: data.y would not rerender! shallowly new equal props are not stored + // return this.props.odata.x; + // } + // render() { + // renderCount++; + // return {this.props.odata.x}-{this.props.data.y}-{this.computed} + // } + // } + // + // const Parent = observer(createClass({ + // render() { + // // this.props.odata.x; + // return + // } + // })) + // + // function stuff() { + // data.y++; + // odata.x++; + // } + // + // render(, container); + // + // expect(renderCount).toBe(1) // 'renderCount === 1'); + // expect(container.querySelector("span").textContent).toBe("1-1-1"); + // + // container.querySelector("span").click(); + // setTimeout(() => { + // expect(renderCount).toBe(2) // 'renderCount === 2'); + // expect(container.querySelector("span").textContent).toBe("2-2-2"); + // + // container.querySelector("span").click(); + // setTimeout(() => { + // expect(renderCount).toBe(3) // 'renderCount === 3'); + // expect(container.querySelector("span").textContent).toBe("3-3-3"); + // + // done(); + // }, 10); + // }, 20); + // }); + + // it('it rerenders correctly if some props are non-observables - 2', done => { + // let renderCount = 0; + // let odata = observable({ x: 1 }) + // + // @observer class Com extends Component { + // @computed get computed () { + // return this.props.data.y; // should recompute, since props.data is changed + // } + // + // render() { + // renderCount++; + // return {this.props.data.y}-{this.computed} + // } + // } + // + // const Parent = observer(createClass({ + // render() { + // let data = { y : this.props.odata.x } + // return + // } + // })) + // + // function stuff() { + // odata.x++; + // } + // + // render(, container); + // expect(renderCount).toBe(1) // 'renderCount === 1'); + // expect(container.querySelector("span").textContent).toBe("1-1"); + // + // container.querySelector("span").click(); + // setTimeout(() => { + // expect(renderCount).toBe(2) // 'renderCount === 2'); + // expect(container.querySelector("span").textContent).toBe("2-2"); + // + // container.querySelector("span").click(); + // setTimeout(() => { + // expect(renderCount).toBe(3) // 'renderCount === 3'); + // expect(container.querySelector("span").textContent).toBe("3-3"); + // + // done(); + // }, 10); + // }, 20); + // }) +}); diff --git a/packages/inferno-mobx/src/index.ts b/packages/inferno-mobx/src/index.ts index c7c333ab1..ccb4f1198 100644 --- a/packages/inferno-mobx/src/index.ts +++ b/packages/inferno-mobx/src/index.ts @@ -1,6 +1,7 @@ import { errorsReporter, inject, Observer, observer, renderReporter, trackComponents, useStaticRendering } from './observer'; import { Provider } from './Provider'; import { EventEmitter } from './utils/EventEmitter'; +import { observerPatch } from './observerPatch'; // THIS IS PORT OF AWESOME MOBX-REACT to INFERNO // LAST POINT OF PORT @@ -8,4 +9,4 @@ import { EventEmitter } from './utils/EventEmitter'; const onError = (fn) => errorsReporter.on(fn); -export { errorsReporter, inject, observer, onError, EventEmitter, Observer, Provider, renderReporter, trackComponents, useStaticRendering }; +export { errorsReporter, inject, observer, onError, EventEmitter, Observer, observerPatch, Provider, renderReporter, trackComponents, useStaticRendering }; diff --git a/packages/inferno-mobx/src/observerPatch.ts b/packages/inferno-mobx/src/observerPatch.ts new file mode 100644 index 000000000..ba41bc171 --- /dev/null +++ b/packages/inferno-mobx/src/observerPatch.ts @@ -0,0 +1,73 @@ +import { InfernoNode } from 'inferno'; +import { warning } from 'inferno-shared'; +import { Reaction } from 'mobx'; + +type RenderReturn = InfernoNode | undefined | void; + +type Render = (this, properties?, state?, context?) => RenderReturn; + +type ObserverRender = R & { dispose: () => void }; + +function makeObserverRender(update: () => void, render: R, name: string): ObserverRender { + const reactor = new Reaction(name, update); + const track = reactor.track.bind(reactor); + const observer = function (this, ...parameters: Parameters) { + let rendered: RenderReturn; + track(() => { + rendered = render.apply(this, parameters); + }); + return rendered; + } as ObserverRender; + observer.dispose = reactor.dispose.bind(reactor); + return observer; +} + +interface Target { + readonly displayName?: string; + readonly forceUpdate: (callback?: () => void) => void; + render: Render; + componentWillUnmount?: () => void; +} + +/** + * Turns a class Component into a MobX observer. + * @param clazz The constructor of the class to patch as a MobX observer. + */ +export function observerPatch(clazz: (new (p: P, c: C) => T) | (new (p: P) => T) | (new () => T)): void { + const proto = clazz.prototype as T; + if (process.env.NODE_ENV !== 'production') { + if ((clazz as { readonly isMobxInjector?: boolean }).isMobxInjector === true) { + warning( + "Mobx observerPatch: You are trying to use 'observerPatch' on a component that already has 'inject'. Please apply 'observerPatch' before applying 'inject'" + ); + } else if ((clazz as { readonly isMobXReactObserver?: boolean }).isMobXReactObserver === true) { + warning( + "Mobx observerPatch: You are trying to use 'observerPatch' on a component that already has 'observer'. Please only apply one of 'observer' or 'observerPatch'" + ); + } else if ((clazz as { readonly isMobXInfernoObserver?: boolean }).isMobXInfernoObserver === true) { + warning("Mobx observerPatch: You are trying to use 'observerPatch' on a component that already has 'observerPatch' applied. Please only apply once"); + } + (clazz as { isMobXInfernoObserver?: boolean }).isMobXInfernoObserver = true; + } + const base = proto.render; + const name = clazz.name; + proto.render = function (this: T, ...parameters) { + const update = this.forceUpdate.bind(this, undefined); + const render = makeObserverRender(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: ObserverRender }) { + this.render.dispose(); + this.render = base as ObserverRender; + unmount.call(this); + }; + } else { + proto.componentWillUnmount = function (this: T & { render: ObserverRender }) { + this.render.dispose(); + this.render = base as ObserverRender; + }; + } +}