[JSX] Make `JSX.create` properties list not limited to static list but also works for conditions
Originally reported on Feliz repo here
It is common to have condition in the properties list to conditionally apply some classes for example:
let Ele() =
JSX.create "div" [
if 1 = 1 then
"className", "first"
]
currently it fails with error FABLE: Expecting a static list or array literal (no generator) for JSX props
This is because in this case a CEs list is generated by the compiler.
We need to look into unwrapping that CEs like we did for the children list and try to manipulate the AST to generate what we want.
After some trial an error, I was able to make
let mutable a = 0
a |> ignore
let Ele() =
JSX.create "div" [
if a = 1 then
"className", "first"
// "className", "first"
]
transpiles to
export let a = createAtom(0);
a();
export function Ele() {
return <div className={(a() === 1) ? "first" : (null)} />;
}
Note: We will need to generate code like
const MyObjSpreadExample = () => {
const shouldHaveBackground = true;
const props = Object.fromEntries([
["id", "myId"],
shouldHaveBackground && ["style", { background: "red" }],
].filter(Boolean));
return <button {...props}>Click me!</button>;
};
Because there could be several times the same props in different conditions block, and the last one take priority.
return <button {...props}>Click me!;
I see a spread
In the example shown above, it seems to be "easy" but what if the condition is not at the top level but in a nested children.
let mutable a = 0
a |> ignore
let Ele() =
JSX.create "div" [
"children", [
JSX.create "div" [
if a = 1 then
"className", "first"
]
]
]
And we can have condition at different levels / on different components, I am uncertain what output we should generate in these cases.
cc @Freymaurer
Just throwing in my 2 cents
Shouldn't be a concern of Fable in my imagination (delegate to Plugins to fret over different methods of implementing this - should be opt-in).
I imagine JSX.create to be analogous to
<div key={value}>
// or depending on library
Component({key: value})
It is not a feature that the keys can be assigned conditionally in the object initializer. The keys can be assigned values conditionally however.
I imagine if you wanted to push it though, all the property assignment, including that of children etc, could be lifted out of the top level tag, and then the object constructed conditionally before being spread into the top level tag. But at that point it's not JSX anymore 😓.
like this? Just let the user do it?
Well the semantics of that is reflected by
JSX.create "div" [
"className" ==> if a = 0 then "first" else ""
]
Which is correct for JSX. There are a lot of assumptions being made and questionable behaviour if you automatically do this though. I think this should be decided by the user using correct semantics.
Let's say there was a component library which has a default icon property. If we were to automatically null a property which did not have an assignment in a branch, then someone might see this:
JSX.create "iconComponent" [
if a = 0 then
"icon" ==> SomeIcon
]
- and think that if
a<>0then the default icon would be there. But you've overwritten it with null. I'm not familiar with JavaScript that much, but would undefined prevent preset properties being overwritten?
Good point!
What we are trying to replicate is the previous logic where you pass one object as prop to the creatElement function. So maybe reusing this logic with a spread operator might be a good solution, as soon as any type of conditional properties are found?
Sorry I think I was not explicit enough about the problems:
let Ele() =
JSX.create "div" [
if a = 1 then
"className", "first"
"data-my-attr", "hello"
]
This is a problem because when we try to rewrite using a pure JSX syntax then in the AST we have a single if statement in the AST that we need to map to 2 JSX properties. If we do it naively we could do something like that:
<div
className={(a() === 1) ? "first" : (null)}
data-my-attr={(a() === 1) ? "hello" : (null)}>
</div>
but this can be problematic because the condition is evaluated twice (could be even more). And if the condition is complex it can slow down the application or if the condition has some side effect it is even worth.
That's what lead to the idea of using Object.fromEntries because I can pass it the F# list output and it generate the correct properties object with the correct evaluation of the condition:
[<Global>]
let Object :obj = jsNative
let mutable a = 0
let x : obj =
Object?fromEntries(ResizeArray [
"id", "myId"
if a = 0 then
"class", "myId"
"style", "red"
else
"id", "myId"
])
JS.console.log(x)
gives Object { id: "myId", class: "myId", style: "red" } which we can pass to JSX via {...props}
The problem that arise now is that inside of a single Component there can be several places where we have condition properties:
export function MyComponent () {
const isGrid = false
return (
<div className={isGrid ? "grid-container" : "stack"}>
<div className={isGrid ? "grid-element" : "stack-element"}>
{/* // ... */}
</div>
</div>
)
}
Now it means we need to generate something like:
export function MyComponent () {
const isGrid = false
const propElement$1 = Object.fromEntries(...)
const propElement$2 = Object.fromEntries(...)
return (
<div {...propElement$1}>
<div {...propElement$2}>
{/* // ... */}
</div>
</div>
)
}
For the same reason (evaluation of the condition), we would probably need to pass the children as a props and not as an HTML tag child element which is not standard JSX and I don't know if all JSX framework supports that.
The other solution, I see would be to generate the standard JSX but instead of using Object.fromEntries to deal with the condition evaluation we would manually move up the condition into a generated variable to store the result of the condition and have it evaluated once only.
let MyComponent() =
JSX.create "div" [
if a = 1 then
"className", "first"
"data-my-attr", "hello"
]
would become
export function MyComponent () {
const condition$1 = (a() === 1)
return (
<div
className={condition$1 ? "first" : (null)}
data-my-attr={condition$1 ? "hello" : (null)}>
</div>
)
}
This last idea is probably the one that gives the best natural JSX output and would allow to keep passing children as JSX intend it:
export function MyComponent () {
const condition$1 = (a() === 1)
return (
<div
className={condition$1 ? "first" : (null)}
data-my-attr={condition$1 ? "hello" : (null)}>
{
condition$1 ?
<div>Condition is true</div>
: <div>Condition is false</div>
}
</div>
)
}
But it is also the one that requires the most AST manipulation, and I am not sure how easy it would be to do or how robust it is.
The more I try to make the JSX conversion work the more I discover edges cases.
To avoid having to rewrite the condition handling like shown above, I tried to inline the Object.entries call like that;
export function Ele1() {
return <div{...Object.fromEntries([(a === 1) ? (["className", "second"]) : ([]), ["children", <div>Hello</div>]])}/>
}
It works, but once we have several children we get the warning from React where each child should have a unique key:
<div{...Object.fromEntries([(a === 1) ? (["className", "second"]) : ([]), ["children", [<div key="1" />, <div key="2" />]]])}/>
I think the real solution would be to do what I mentioned in https://github.com/fable-compiler/Fable/issues/4119#issuecomment-2959893346 where we extract the condition at top level:
export function MyComponent () {
const condition$1 = (a() === 1)
return (
<div
className={condition$1 ? "first" : (null)}
data-my-attr={condition$1 ? "hello" : (null)}>
{
condition$1 ?
<div>Condition is true</div>
: <div>Condition is false</div>
}
</div>
)
}
But it will probably require a lot of works, unfortunately I don't think I have the ability to do that. If someone from the community want to give it a try, we are always open to contribution.
I pushed my local version of the code on this branch, it contains of a lot prototype and is probably not the right path to take. But in case, it can help I decided to push it as is.
Edit: I am also taking the decision to take a break working on this feature because while I struggle working on it, it means I can't work on Fable 5 release for example. Which I think would have a bigger impact right now.
If someone is looking for a pure F# alternative to React @thinkbeforecoding made https://codeberg.org/thinkbeforecoding/Fastoch
I didn't have the chance to test it out yet but depending on your usage of React, Solid.js, etc. it can be worth checking out.
I know for example, that in my case I write my own components in general and don't use existing ones, so in this situation Fastoch could be enough for me.