[Feedback] Preserve JSX mode

When working with SolidJS, you always have to keep the basic reactivity concepts in mind, the interactions between signals, subscribers and tracking scope.

Signals provide getter and setter functions to work with data. If a getter is used within a tracking scope, all code within that scope will be rerun if the signal updates.

A subscriber basically defines a tracking scope. The most simple example would be the createEffect function. Any getter used within createEffect will “subscribe” to the corresponding signal and will automatically be updated (rerun) whenever the signal changes.

Any JSX component will also create its own tracking scope.

This has some interesting implications when working with SolidJS. For example the following code would not work:

function App() {
  const [maybe] = createSignal("hi");

  return (
      maybe() ? <div>{maybe()}</div> : null
  );
}

Even though the getter (maybe()) is used correctly (used twice), The first one is outside of any tracking scope. So the component will never update.

Just adding a fragment (<>) would fix this, because the fragment would create its own tracking scope.

function App() {
  const [maybe] = createSignal("hi");

  return (
      <>maybe() ? <div>{maybe()}</div> : null</>
  );
}

This is of course somewhat hacky, and therefore it is recommended to instead use the <Show> component.

function App() {
  const [maybe] = createSignal("hi");

  return (
    <Show when={maybe()}>
      <div>maybe()</div>
    </Show>)
}

The Show component will automatically generate a tracking scope and subscribe to all relevant signals.

While at first, this seems complicated, it is something that every experienced SolidJS programmer will know well, and they would easily spot errors like the one above.

The same would be true for this snippet (that would not work either):

function App() {
  const [maybe] = createSignal("hi");
  const m = maybe();

  return (
      <> m ? <div>{m}</div> : null</>
  );
}

It is easy to spot, that the getter is called outside a tracking scope. So no updates will be triggered.

The point I’m trying to make is that, although SolidJS can be complicated, it is also very explicit. In many cases the error is obvious and easy to spot, just by looking at the code.

Basically this line const m = maybe() would be a huge red flag.

Now let’s have a look at ReScript.

@react.component
let make = () => {
  let maybe = createSignal(Some("hi"))

  switch maybe() {
    | Some(m) => <div>{m->React.string}</div>
    | _ => React.null
  }
}

I would say that for the average ReScript programmer this would look like valid code. A SolidJS programmer, coming to ReScript, would probably think the same. (Although they might be irritated by the use of a getter inside the switch statement.)

But in order to get the error right away, you need a decent understanding of how the compiler transforms the code to JavaScript.

The problem only becomes obvious when looking at the generated JS code. The getter is clearly called outside a tracking scope:

function App() {
  let maybe = SolidJs.createSignal("hi");
  let m = maybe();
  if (m !== undefined) {
    return <div>
    {m}
    </div>;
  } else {
    return null;
  }
}

The problem also persists if using the “fragment trick” in the TypeScript example. Adding a fragment would result in this code:

function Broken(props) {
  let maybe = SolidJs.createSignal("hi");
  let m = maybe();
  return <>
  {Primitive_option.some(m !== undefined ? <div>
    {m}
    </div> : null)}
  </>;
}

That’s why the problem is much more relevant for ReScript than it is for TypeScript. Those transformations and small optimizations that the ReScript compiler does, can lead to problems that would not happen in TypeScript.
And those changes are hidden when only looking at the ReScript code.
You have to know how a ReScript switch statement is represented in JavaScript, in order to be able to spot the problem.

So what I’m trying to say is, that neither a ReScript programmer coming to SolidJS nor a SolidJS programmer coming to ReScript, will have the necessary understanding and intuitions about the code. Because it is necessary to have a good understanding of the ReScript compiler and of reactivity in SolidJS.
This can easily lead to frustration and the believe that SolidJS and ReScript do not work together.

(Honestly it would be interesting to know, how many ReScript programmers actually check the resulting JS code. My assumption is that most people do, but I’m not sure about it).

But those small optimizations are one of the great features of ReScript (at least for me), that make it so much easier to write good code without having to think too much about it.
Only in this very specific case, when using SolidJS, it sadly makes it more complicated.

So, from my perspective, solving those problems should not be done by changing the way the ReScript compiler behaves. It is more about finding a good way to make those interactions more explicit and easier to spot.

My first step would be to just add a little more documentation and simply explain those interactions. That would make it much easier for a newcomer, to avoid those mistakes.

Thinking about it. When I update rescript-solidjs for ReScript V12, I will probably just add some explanations in the documentation. That should already be a great improvement.

2 Likes

Thank you for the thorough write up! It would be interesting to think about whether there’s anything additional we can do to make the clearer/easier to work with. I look forward to seeing how things look with v12 and rescript-solidjs!

1 Like

While updating the bindigs to v12, I ran into another complication.

SolidJS uses slightly different DOM attributes. For example in SolidJS class is allowed, and there are also special ones like classList that exist only in SolidJS.

With v11 I was using the generic JSX transform feature to modify the domProps type by providing my own definition of the Elements module (as described in the docs):

module Elements = {
  type props = {
    ...JsxDOM.domProps,
    class?: string /* Solid also allows class */,
    classList?: classList,
    textContent?: string,
  }
}

With v12 it seems generic JSX transform and preserve mode are mutually exclusive. (At least in my tests I could not get them to work together). Which seems intuitive at first glance, since defining your own function names is not necessary if those functions are never created.
But now I can no longer use it to modify domProps.

I found a solution that works, by simply providing my own JsxDOM module:

module JsxDOM = {
  type style = JsxDOMStyle.t
  type domRef

  type popover = | @as("auto") Auto | @as("manual") Manual | @as("hint") Hint

  type popoverTargetAction = | @as("toggle") Toggle | @as("show") Show | @as("hide") Hide
  type domProps = {
    ...JsxDOM.domProps,
    class?: string /* Solid also allows class */,
    classList?: string,
    textContent?: string
  }
}

But this seems much less elegant than the previous solution.

Would it be possible to have JSX transform and preserve mode active at the same time (with preserve mode taking precedence)?

Or do you have another idea on how to solve this in a cleaner way than just overriding JsxDOM?

With v12 it seems generic JSX transform and preserve mode are mutually exclusive. (At least in my tests I could not get them to work together).

What exactly are you testing? Can you give a playground example?

JSX transform needs to be active for JSX preserve to work.
So, I’m a little lost about what you are trying.

Sorry, I don’t know how I can set the necessary compiler flags in the playground.

But I just tried to build a simplified example and something strange happend.

I have this simple component:

@jsx.component
let make = () => {
 <div className="text"></div>
}

When using these settings in my rescript.json:

{
  "bsc-flags": [ "-bs-jsx-preserve" ],
  "jsx": {
    "version": 4,
    "module": "MyOwnJsx"
  }
}

it actually works and the component gets compiled as expected.

function Btn(props) {
  return <div className={"text"}/>;
}

But when I change the code in my MyOwnJsx.res:

module Elements = {
  type props = JsxDOM.domProps

  @module("myownjsx")
  external jsx: (string, props) => Jsx.element = "jsx"

and remove the @module("myownjsx") part:

module Elements = {
  type props = JsxDOM.domProps

  external jsx: (string, props) => Jsx.element = "jsx"

the code will be compiled to:

function Btn(props) {
  return jsx("div", {
    className: "text"
  });
}

So I think the solution would be to simply provide the dummy @module entry. But the bahaviour was a bit surprising for me.

Okay, I’m still a little confused. So, are there two things here?

  • Preserve jsx doesn’t work with "module": "MyOwnJsx" in rescript.json? That could very well be the case.
  • class is not respected? I think having your own JsxDOM.domProps will probably be a requirement there.

Could you maybe produce a sample repo? Would like to explore the first point there.

I will create a sample. Meanwhile I also ran into another problem. I managed to make it work as described above, but it seems that using fragments (<></>) also doesn’t work as expected.

Using a custom Module, the fragment will always be replaced by a module name (e.g. <MyOwnJsx.Fragment></MyOwnJsx.Fragment> vs <></>)

I will include that in the example too.

Thanks! I believe these are solvable edge cases we haven’t encountered yet.

1 Like

Many thanks for your help!
I created the sample repo now: GitHub - Fattafatta/rescript-v12-test: Test of JSX preserve mode and added some basic instructions in the README.

Hi @Fattafatta , @cristianoc just fixed your problem in Fix issue with preserve mode where `jsx` is declared as an external w… by cristianoc · Pull Request #7591 · rescript-lang/rescript · GitHub

You can try npm i https://pkg.pr.new/rescript-lang/rescript@7591 if you like.

2 Likes

This is great timing because Deno’s Fresh V2 now requires JSX for its render functions.

1 Like

In the latest betas, you can now enable preserve mode via

{
  "suffix": ".res.jsx",
  "jsx": {
    "version": 4,
    "preserve": true
  },
}

However, this won’t get picked up if you use rescript legacy build.
This is deliberately, we hope to encourage you to move to the new build system.

3 Likes