atom / etch

Builds components using a simple and explicit API around virtual-dom

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Changing the root DOM node of a component

BinaryMuse opened this issue · comments

Since Etch components must opt-in to updates via the update method, and Etch updates each one individually via performElementUpdate, there is a situation where patching does not work as expected. Here is a test case to demonstrate:

class Component {
  constructor () {
    this.renderDiv = true
    etch.createElement(this)
  }

  render () {
    if (this.renderDiv) {
      return <div>Imma Div</div>
    } else {
      return <span>Imma Span</span>
    }
  }
}

let component = new Component()
component.renderDiv = false
etch.updateElementSync(component)

If you're anything like me, you might be surprised that component.element.outerHTML is still <div>Imma Div</div>. This actually makes perfect sense, because virtual-dom can't actually patch the div to make it a span; in fact, patch returns a new root node so that you can do whatever you need to with it.

This is particularly troublesome in Etch because each component's element is patched independently; for example, imagine a scenario where a top-level component renders a different component depending on a flag, and those components each render yet another component:

┌───────────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │                          <Grandparent />                          │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│                                   │                                   │
│             Flag True             │            Flag False             │
│                                   │                                   │
│                                   │                                   │
│ ┌───────────────────────────────┐ │ ┌───────────────────────────────┐ │
│ │          <ParentA />          │ │ │          <ParentB />          │ │
│ └───────────────────────────────┘ │ └───────────────────────────────┘ │
│                 │                 │                 │                 │
│                 │                 │                 │                 │
│                 ▼                 │                 ▼                 │
│ ┌───────────────────────────────┐ │ ┌───────────────────────────────┐ │
│ │          <ChildA />           │ │ │          <ChildB />           │ │
│ └───────────────────────────────┘ │ └───────────────────────────────┘ │
│                 │                 │                 │                 │
│                 │                 │                 │                 │
│                 ▼                 │                 ▼                 │
│ ┌───────────────────────────────┐ │ ┌───────────────────────────────┐ │
│ │            <div />            │ │ │           <span />            │ │
│ └───────────────────────────────┘ │ └───────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘

In this case, it's not obvious that the root DOM node of Grandparent might change from a div to a span, since ChildA and ChildB may be far removed, or rendered only in certain conditions, or among a larger tree of other components.

@nathansobo and I discussed a few ways to handle this issue (Nathan please remind me if I forgot any):

  1. Just don't support returning a different type of root DOM node from a component's render

    This has potential advantages conceptually (e.g. a component only ever maps to a single DOM node, which will always be the same reference). A small change to performElementUpdate to detect the root node type changing could be used to implement this cleanly.

    To deal with this, I'd imagine components would either have their contents wrapped in lots of divs or else components would need to be less fine-grained than you might see in other component frameworks (e.g. React).

  2. Automatically swap out nodes in Widget#destroy if we detect the node has a parent

    In this case, we would do what I think the user probably expects most of the time. It doesn't work for the root DOM node of the root component unless the user has attached it to the DOM before it updates for the first time, but works nicely for components further down the component hierarchy. One potential downside of this technique is that the user doesn't have an opportunity to clean up e.g. event handlers or other references to DOM nodes.

    We realized that the reason this isn't an issue with React is the synthetic event system.

  3. Provide an API that is called when the root node is changed / needs to be updated

    This would solve the issue when the root DOM node for the root component changes (as discussed in option 2), and would allow users to have a chance to clean up references to the nodes if necessary. However, this introduces a fair amount of new complexity into the library, and it's unclear if it's worth it.

  4. Force the user to provide a container

    This type of issue is probably why React, et al. requires a container, but we'd like to avoid this.

After thinking about this a bit, I think I like 2 the best, implemented for all root component DOM nodes except for the root component's root node — that is, if a non-root component somewhere in the component hierarchy changes node type, we can switch out the DOM node for the user. Perhaps a constraint that the root component's DOM node can't change is enough; simply wrapping all of the root component's render return value in a <div> or something is enough to satisfy this constraint.

I still have concerns about event handling, but not sure what the best way to approach / address, if any, is.

How do we know that a component is the root component? We could set some state on components we embedded within the JSX of another component, but that seems unnecessarily complex. If we allow a component's element to be changed, it seems like we'll need some sort of indicator no matter what... so here's an idea.

Rather than assigning the .element property on the component etch.createElement and etch.updateElement could instead call setElement and getElement methods on the component as needed. We could then encourage users to add their event listeners in the setElement method, which would be called on first creation and any time a call to etch.updateElement needed to swap out the root element. If the previous element is on the DOM, we can also automatically replace it as part of updateElement.

This seems general enough to allow in the root component as well. If you're building a component you intend to expose to others for imperative use, you could make the element observable with an onDidChangeElement method or something along those lines.

I looked through a number of larger projects I have lying around written in React, and the case where a component's root node type isn't that common, but does happen. I think we could get away with throwing an exception in these cases for now, and implement a more complex/robust solution if the need arises.

@BinaryMuse can you explain a few scenario when it is the proper thing to do ? VS having a div/span that wrap element A or B you want to change ?

@jeancroy Wrapping the changing component in a wrapper element is, I think, a good workaround for the issue. The bigger problem (IMO) is that, currently, changing the root node type for a component both doesn't work, and also fails silently. I ran into this while writing unit tests for another feature and it took me some time to track it down, so I think etch should either (1) support the feature, or (2) explicitly throw when it happens. (To be clear, I'm recommending (2) for now :)

Incorrectly marked #11 as fixing this, #12 is actually the PR that fixes this issue.