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):
-
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
div
s or else components would need to be less fine-grained than you might see in other component frameworks (e.g. React). -
Automatically swap out nodes in
Widget#destroy
if we detect the node has a parentIn 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.
-
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.
-
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 :)