rescript-compiler icon indicating copy to clipboard operation
rescript-compiler copied to clipboard

Bug/question: using custom JSX functions

Open ersinakinci opened this issue 5 years ago • 5 comments

I have a question, but it could potentially be a documentation bug. Also, I've asked my question in three different venues and I think that maybe it's too low-level for most people to answer, so I'm reposting it here; I hope that's OK.

I'm brand new to Reason/ReScript and I'm evaluating how our team might use Reason in our React project.

We've hit one major stumbling block. Our project uses Theme UI for CSS-in-JS styling, which works like this:

  1. Each styled component gets a special sx prop that contains a style object. E.g., <div sx={{ margin: '4px' }} /> .
  2. Each .jsx/.tsx file that has sx props in it is marked with a special JSX pragma at the top (e.g., /** @jsx jsx */ ), which tells Babel to use jsx(...) calls instead of React.createElement(...). This behavior is part of Babel, not Theme UI, and you can use whatever function you like (in Babel's docs, they use Preact.h). Theme UI's jsx is a function like React.createElement that also knows how to handle the sx prop. It's imported from Theme UI like so: import { jsx } from 'theme-ui' .

So basically, I need to figure out how to use Theme UI's jsx function to create my elements rather than React's createElement. I also need to figure out how to get sx into the prop type for JSX elements, but that's another battle.

My first hunch was that the limitation had to do with ReasonReact, so I opened an issue.

I realized later that the limitation is at a deeper level, with something called the JSX ppx. It appears that ppx's are an OCaml mechanism for extending the language through AST preprocessing?

I looked up that particular ppx. It appears that calls to React.createElement are hardcoded, but it's hard to tell if that's a default or if it's the only option.

Then, I came across this bit of documentation in the ReScript manual:

For library authors wanting to take advantage of the JSX: the @JSX attribute above is a hook for potential ppx macros to spot a function wanting to format as JSX. Once you spot the function, you can turn it into any other expression.

This way, everyone gets to benefit the JSX syntax without needing to opt into a specific library using it, e.g. ReasonReact.

The docs are unclear here, hence why I wrote that perhaps there's a documentation bug. On the one hand, the docs imply that I can somehow leverage "the @JSX attribute" provided by the ppx to transform my JSX into JS using a custom function--that's exactly what I'm looking for. On the other hand, it implies that I need to write my own ppx to get this to work, defeating the purpose of leveraging all the work already put into the JSX ppx.

I'm also not sure what "spot the function" means in this context. I naively tried writing @JSX div(~onClick=handler, ~children=list{child1, child2}, ()) and got this error: Unbound constructor JSX.

I tried searching the ReasonReact source code for any mention of "jsx" and I couldn't find any. I see the "reason": { "react-jsx": 3 } in bsconfig.json, but I'm not sure how the ppx in ReScript and ReasonReact are connected, nor how I could leverage the ppx to support my own use case (i.e., Theme UI's jsx function).

Does anyone have an idea for how to tackle this problem? Thanks!

ersinakinci avatar Sep 15 '20 17:09 ersinakinci

@bobzhang @rickyvetter, git blame suggests that you're most familiar with that ppx. Sorry to single you out 😂. Any insights?

ersinakinci avatar Sep 15 '20 17:09 ersinakinci

Anyone?

ersinakinci avatar Sep 23 '20 22:09 ersinakinci

I've made some progress. Here's what I know so far.

JSX transformation takes place in two phases:

  • Rescript replaces raw JSX with calls to [@JSX] ... in jscomp/js_parser/jsx_parser.ml. So <div props1={a} props2={b} /> becomes [@JSX] div(~props1=a, ~props2=b, ()). This is a kind of intermediate format that's intended to be further transformed into something specific to a particular JSX-based framework, e.g. React.
  • [@JSX] ... calls get replaced with ReasonReact-specific calls by jscomp/syntax/reactjs_jsx_ppx.cppo.ml. So [@JSX] div(~props1=a, ~props2=b, ()) becomes ReactDOMRe.createElement("div", ~props={"props1": a, "props2": b}).

The first phase is built into the Rescript parser itself. I believe that it executes no matter what.

The second phase is hooked into as a PPX here and here (it gets built as the module Reactjs_jsx_ppx_v3 here). Adding {"reason": "react-jsx": 3} triggers the PPX and the second transformation.

So basically, what needs to be done is:

  1. Write a PPX that replaces the output from the first phase with calls to a different kind of createElement, like Theme UI's jsx function. Perhaps this is as easy as search/replace ReactDOMRe with ThemeUI, then declare an external function createElement on a ThemeUI module that points to Theme UI's jsx function.
  2. Write bindings for Theme UI.
  3. Wire up the new PPX to the build process.

ersinakinci avatar Sep 24 '20 17:09 ersinakinci

Hey @earksiinni - your research seems to be pretty solid - congrats on digging through this. A lot of the reason this is under-documented is because PPXs are very hard to write in a way that makes the developer experience great, so we don't really want to encourage a ton of them.

Assuming that you want to match the JS/TS style behavior then you'll need an additional step which is an actual pragma switch. Sharing one potential example of how that might look here (astexplorer is a great tool for looking at the actual ast output but only supports OCaml and Reason syntax today) - https://astexplorer.net/#/gist/708dd752ed77f2aeab9d88d0e8a42703/4475dfafca43d71c291cb6f7293c2876a88e6fa6

Then your PPX needs to respect that pragma and allow code to fall through to the default PPX compilation if it doesn't see it (or if you're using <UpperCase/> components or something).

Feel free to DM on Discord or post more questions here.

rickyvetter avatar Sep 25 '20 17:09 rickyvetter

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Sep 16 '22 22:09 stale[bot]