Ownership of class when patching
Hello,
I am using Hyperapp with various libraries of web components, some of which require particular classes to be dynamically added to trigger specific behaviors. It seems to me that when Hyperapp patches the class property, it completely overwrites the existing classes on the node, including the classes those web components set for their internal behavior. Is it possible that the diff is performed on just the classes that Hyperapp "owns", just like it does for 'style'?
I believe this was touched upon before here https://github.com/jorgebucaran/hyperapp/issues/1078 and I understand that Hyperapp needs complete control over the DOM. While I agree this should be the ideal case, in practice the class list is a bit of a recurrent problem, as large libraries seem to (still) be using dynamic classes as user-facing properties.
Just to clarify, this refers to third party web components that Hyperapp otherwise handles quite nicely. The issue is not in other scripts messing with the DOM tree of Hyperapp itself, it's in using
h("third-party-web-component", { class: { customClass: true} })
where third-party-web-component already has some implicit predefined classes.
From my limited understanding of Hyperapp's internals I presume this is not possible when class is specified as a string, but could probably work with array and object, which would be just fine to use.
On second thought, passing class as string ensures that patching is only attempted when the class did change, so probably splitting it back won't be that much of a performance problem?
Something like:
// patchProperty
else if (key === "class") {
var o = oldValue ? oldValue.split(" ").filter(x => x.trim()) : [];
var n = newValue ? newValue.split(" ").filter(x => x.trim()) : [];
var r = o.filter(x => !n.includes(x));
var a = n.filter(x => !o.includes(x));
node.classList.remove(...r);
node.classList.add(...a);
}
Or a more efficient/minimal version of this. Manipulating classList explicitly allows compatibility with some rather large libraries, in my case, Ionic.
I'm not sure how Ionic is adding those "implicit classes" (I'd guess in the web-components connectedCallback function, but it could be from externally too I suppose). In any case I see basically four options. In order of preference:
- Rearchitect so that you separate the control of Ionic and hyperapp on your pages. Use effects & subscriptions in the hyperapp parts to communicate with the Ionic parts.
This is probably the best because both neither Hyperapp nor Ionic are really anticipating eachother and trying to get them to play nice with the same dom nodes is probably going to lead to other headaches as well. FWIW this would probably be the same with any DOM-diffing framework – not just hyperapp.
- Instead of adding customClass as a class, add it as a data attribute:
h('third-party-web-component', {
'data-mynamespace-customclass': true,
})
and change your css from:
.customClass {
/* some rules */
}
to:
[data-mynamespace-customclass] {
/* some rules */
}
-
If it's possible to anticipate the implicit classes, hardcode them into a component that wraps the web-component.
-
There is a hack which you can use sparingly in edge cases (it is unsupported and not guaranteed to work in the future, and probably will give you a bunch of headaches, but you know ... we all need to get our hands dirty from time to time) which allows you to capture the actual dom-node of a component, and hook into its mount/patch lifecycle hooks. You could use that to inspect the implicit classes, and add them back after hyperapp removes them. See: https://dev.to/zaceno/access-dom-elements-for-virtual-nodes-in-hyperapp-2f5k
Thanks for your answer.
Here's a more explicit description of the problem:
- ion-input is created from a Hyperapp view
- ion-input initializes with classes like "ios input-fill-solid input-label-placement-floating hydrated". Hyperapp does not know about that. Everything looks and acts ok.
- user inputs wrong data
- to show a validation message, ion-input requires that I add 'ion-invalid' and 'ion-touched' classes to the control. This should result in "ios input-fill-solid input-label-placement-floating hydrated ion-invalid ion-touched"
- when I set it through the Hyperapp view, I only get "ion-invalid ion-touched", the classes the control was initialized with are overwritten.
I do not see anything particularly shady about dynamically adding classes in either Hyperapp or in a web component, even though in this particular case would have been a better option for the control to have properties for that. It's not styling that needs to be controlled by the class, it's behavior. But that's how it works for now.
The point is, those classes have different scope. 'ion-invalid' would only be set from Hyperapp, 'hydrated' would only be set from the web component.
I guess a workaround would just be to not touch the classes through the Hyperapp view at all for some particular controls, and just explicitly handle them through node.classList as an effect. Yet, Hyperapp plays nice with most web components, it would be great to handle this for the class list as well. It already does this for style, which does not get overwritten in a similar scenario. That's because styles are explicitly handled when patching, comparing the old value to the new value. "Unknown" styles are not affected, allowing the web component to go crazy on dynamically styling itself. For class, patching simply sets the class list as a fully formed string, overwriting classes it does not "own".
I ended up locally patching Hyperapp to handle class property through node.classList, like stated in my previous post. Of course, there can still be conflicts if Hyperapp and the web component try to change the exact same class or style property, but that's not a scenario that can be handled in any meaningful way. What I think does need to be handled is a distinction between "public classes", controlled by Hyperapp from the outside, and "private classes", unknown to Hyperapp, controlled internally by the web component, and this can be achieved through a diff.
I guess a workaround would just be to not touch the classes through Hyperapp at all for some particular controls, and just explicitly handle them through node.classList as an effect.
If that's a possibility yeah that might be a good option too. But since it sounds like you know what classes are added by ionic, you could put them in as well. Or you could perhaps use a mutationobserver in a subscription to watch for classes added to certain nodes, keep them in the state, and add them to the component class list that way.
Or frankly - doing what you did and patching hyperapp to suit your use-case is a perfectly fine option as well. It's not like hyperapp is coming out with a constant stream of updates, so you're not creating a maintenance burden for yourself. You're just taking some code that is almost what you need, and tweaking it to suit you.
But I hear you - it would be nice if Hyperapp wasn't so sensitive to other tools/libraries messing with classes. I've run in to this issue before, myself (in a different context that didn't involve web-components). So, if it matters to you, feel free to submit your patch! Not guaranteeting anything (I'm just a contributor myself) but if it doesn't add too much in terms of minimized & gzipped bytes, there is certainly value here. (Just don't forget to make tests as well)
Just in case it interests you, I hacked together a decorator component that will protect dynamic classes from being overwritten by hyperapp, while allowing you to control other classes from your hyperapp components:
const ProtectDynamicClasses = ({controlled, node}) => {
let elem
const node2 = {
...node,
props: {
...node.props,
class: undefined
}
}
Object.defineProperty(node2, 'node', {
get () { return elem },
set (e) {
elem = e
controlled.forEach(c => elem.classList.remove(c))
elem.setAttribute('class', elem.getAttribute('class') + ' ' + node.props.class)
}
})
return node2
}
The idea is you pass the node who'se classes you want to protect to the node prop, and the controlled property is an array of classNames that you may want to control from the node. Any classNames not mentioned in there, will be left alone during patching.
Here's an example in action: https://tinyurl.com/2zv4vybf
Never mind the text in the grey box. I forgot to change it when I made the example more complicated. The point is that you can set/unset background and color of the text using classes from outside the app, and the app controls classes that make the text bold and italic. Both systems work in parallel without stepping on eachothers toes, thanks to the ProtectDynamicClasses decorator component