MVCoconut / coconut.vdom

Coconut rendering through virtual-dom.
The Unlicense
19 stars 8 forks source link

Function view does not update while class view does #45

Closed T1mL3arn closed 2 years ago

T1mL3arn commented 2 years ago

Trying to implement something similar to react portals using Renderer.mount() and viewDidMount() I found some unexpected(?) behavior: function view does not update when parent state changes

import coconut.vdom.Renderer;
import coconut.vdom.View;

class TestBugView extends View {
    @:state var count:Int = 0;

    function viewDidMount() {
        Renderer.mount(js.Browser.document.getElementById('fragment'), <>Fragment: {count}</>);
        Renderer.mount(js.Browser.document.getElementById('element'), <span>Element: {count}</span>);
        Renderer.mount(js.Browser.document.getElementById('function'), <FunctionView count={count} />);
        Renderer.mount(js.Browser.document.getElementById('class'), <ClassView count={count}/>);
    }

    function render()
        return <button onclick={count += 1}>click me</button>;

    function FunctionView(attr:{ count:Int })
        return <span>Function: {count}</span>;
}

class ClassView extends View {
    @:attr var count:Int;
    function render()
        return <span>Class: {count}</span>;
}
other files hxml ```hxml -cp src -main TestBugMain --library coconut.ui --library coconut.vdom -dce full -D analyzer-optimize -D js-es=6 -js bug\bundle.js ``` Main ```haxe class TestBugMain { static public function main() { Renderer.mount(js.Browser.document.body, ); } } ``` html ```html
```

Clicking the button updates only class view, while I expect updating function component as well.

back2dos commented 2 years ago

Well, this is not really unexpected. Even in react, if you mount a functional component directly, it will not update when external state it depends on changes. It only rerenders if:

  1. it has internal state defined via hooks (but then it's not really a functional component anymore - in the sense that it is no longer pure)
  2. it is rendered by a stateful component, which rerenders as the result of a state change and propagates the new data down.

If you just go ReactDOM.render(<FunctionComponent count={something} />, container)) the component is not going to rerender if something changes, unless for example something is backed by mobx and you made FunctionComponent an observer, in which case it is no longer a pure functional component.

Coconut does not even have functional components per se (at least for now). Functions are simply called and the returned vdom is processed as if you had written it inline.

However, you can put arbitrary subscription/update cycles into any part of the vdom with coconut.ui.Isolated, if you want.

Renderer.mount(js.Browser.document.getElementById('function'), <Isolated><FunctionView count={count} /><Isolated>);

It's primarily meant to be used within big views to avoid updating the whole DOM when a small part changes, as shown in this simplistic example:

class Counter extends View {
  @:state var count = 0;
  var rendered = 0;
  function render()
    return <button onclick={count++}>(rendered {rendered++} times) <Isolated>counter: {count}</Isolated></button>;
}

If you try that, you'll see that the Counter view itself never needs to rerender - although when removing the Isolated, the change affects the whole view.

Personally, I would advise against using anything like react portals. So far I've failed to find a use case that isn't better covered by other methods. The example given in the doc are modals, which I would propose implementing via implicits. Among other things that'll allow non-global management of modals (imagine your application has its visual area split in two and each should be allowed to its own modal) and makes testing easier to (the portals based implementation relies on document.getElementById('modal-root'); actually returning something).

Here's a contrived implementation to illustrate the general approach:

import coconut.ui.*;
import coconut.Ui.hxx;
import js.Browser.*;

function main() {
  Renderer.mount(
    document.body,
    <div style={{ height: '100%', width: '100%', textAlign: 'center' }}>
      <div style={{ height: '40%'}}>
        <Modals>
          <Opener />
        </Modals>
      </div>

      <Opener />

      <div style={{ height: '40%'}}>
        <Modals>
          <Opener />
        </Modals>
      </div>
    </div>
  );
}

class Opener extends View {
  @:implicit var modals:Modals;
  function render()
    return <button onclick={modals.open(<Modal onclose={modals.close()} />)}>Open</button>;
}

class Modal extends View {
  @:attribute function onclose() {}
  function render()
    return <div style={{ background: 'white', width: '50%', height: '50%', position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', padding: '20px', boxSizing: 'borderBox', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', alignItems: 'center' }}>
      Look at me, I am a modal!
      <button onclick=${onclose}>Close</button>
    </div>
  ;
}

@:default(Modals.DEFAULT)
class Modals extends View {
  static var counter = 0;
  public final id = counter++;
  @:children var content:Children = null;
  @:state var current:haxe.ds.Option<RenderResult> = None;

  public function open(v)
    current = Some(v);

  public function close()
    current = None;

  function render()
    return <div style={{ width: '100%', height: '100%', position: 'relative' }}>
      <Implicit defaults={[Modals => this]}>{...content}</Implicit>
      <switch {current}>
        <case {Some(v)}>
          <div style={{ position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', background: 'rgba(0,0,0,.25)', pointerEvents: 'auto' }}>{v}</div>
        <case {_}>
      </switch>
    </div>
  ;

  static public var DEFAULT(get, null):Modals;
  static function get_DEFAULT()
    return switch DEFAULT {
      case null:
        var container = document.createDivElement();
        container.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none';
        document.body.appendChild(container);
        Renderer.mount(container, <Modals ref={v -> DEFAULT = v}/>);
        DEFAULT;
      case v: v;
    }
}
T1mL3arn commented 2 years ago

Thanks for such detailed explanation and modal example. Though it is not quite for my case.

Sometimes you have to escape your views layout to avoid styling problem. For example, I am making a userscript that adds views to different places in existing markup (those views include modals as well as other elements). I see no way to do this otherwise than

// <App /> viewDidMount():
Renderer.mount(targetA, <OpenModal />);
Renderer.mount(targetB, <Modal show={state.showModal} />);
Renderer.mount(targetC, <AnotherThing />);

(then I found this issue subject (when my views was functions they did not work) but it appeared to be designed that way and I can fix it anyway using class views)

I could try to patch site's styles to fit my needs, but I believe it is not a good solution (and might be cumbersome), so I tried to craft portal analogue which allows to do things as simple as this:

// <App /> render():
return (
<div>
  <AdminPanel />

  <if {state.showDialog== true}>
    <Portal target={document.body}>
      <Dialog text={state.dialogText} />
    </Portal>
  </if>

  <Portal target={somewhereInFooter}>
    <Foo value={state.fooValue}>
      <Bar value={state.barValue} />
    </Foo>
    <Bazz />
  </Portal>

</div>
);

And this mostly works for simple static layout inside portals (and sometimes for dynamic layout). Though in some cases when such portals get unmounted the app crashes (but this is a subject for separate issue-question).

back2dos commented 2 years ago

Sometimes you have to escape your views layout to avoid styling problem.

In the example I gave the Opener that's not nested in Modals makes its modal render completely outside its own view hierarchy. Nothing about the approach forces you render anything into any other particular thing.

The point is that instead of arbitrarily and directly rendering into other parts of the DOM that may or may not be there and that you hardcode in unrelated parts of the view hierarchy, you operate on an implicitly passed entity (the cleanest implementation would pass around a narrow interface rather than a full blown view), for which you have the guarantee that it'll always be there. Among other things, that's easier to test.

The code that you're looking to write depends on both global DOM (document.body) and internal structure of other components (somewhereInFooter). I advise against both ;)