Fable icon indicating copy to clipboard operation
Fable copied to clipboard

[JSX] Make `JSX.create` properties list not limited to static list but also works for conditions

Open MangelMaxime opened this issue 8 months ago • 11 comments

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.

MangelMaxime avatar May 09 '25 21:05 MangelMaxime

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)} />;
}

MangelMaxime avatar May 09 '25 21:05 MangelMaxime

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.

MangelMaxime avatar May 14 '25 15:05 MangelMaxime

return <button {...props}>Click me!;

I see a spread

Image

shayanhabibi avatar May 18 '25 18:05 shayanhabibi

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

MangelMaxime avatar Jun 09 '25 19:06 MangelMaxime

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 😓.

shayanhabibi avatar Jun 09 '25 20:06 shayanhabibi

like this? Just let the user do it?

Image

Freymaurer avatar Jun 10 '25 09:06 Freymaurer

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<>0 then 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?

shayanhabibi avatar Jun 10 '25 09:06 shayanhabibi

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?

Freymaurer avatar Jun 10 '25 09:06 Freymaurer

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.

MangelMaxime avatar Jun 10 '25 16:06 MangelMaxime

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.

MangelMaxime avatar Aug 15 '25 15:08 MangelMaxime

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.

MangelMaxime avatar Aug 15 '25 15:08 MangelMaxime