Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Generic JSX transform #6565

Merged
merged 8 commits into from
Jan 29, 2024
Merged

[RFC] Generic JSX transform #6565

merged 8 commits into from
Jan 29, 2024

Conversation

zth
Copy link
Collaborator

@zth zth commented Jan 14, 2024

This is a work in progress. Please feel free to give any feedback.

This generalizes the JSX (v4) transform to not only work on React, but any module that can fulfill the interface needed for the JSX transform.

Configuration

You configure a generic JSX transform by putting any module name in the module config of JSX. This can be any valid module name. So you can define your own MyJsx.res locally if you wanted to, and it'd still work. Example part from rescript.json:

"jsx": {
  "module": "Preact"
 },

This will now put the Preact module in control of the generated JSX calls. The Preact module can be defined by anyone - locally in your project, or by a package. As long a it's available in the global scope. The JSX transform will delegate any JSX related code to Preact.

@react.component will still be available, and so is a generic @jsx.component notation. Both work the same way.

One thing is important to note - the generic JSX transform has no version or mode like the React JSX transform has. It's always JSX v4, and the mode is irrelevant (it's automatic internally for the generic JSX transform). This doesn't matter for the generic JSX module, it only needs one implementation, but it's good to know.

Usage example

Here's a quick usage example (the actual definition of Preact.res comes below):

First, configure rescript.json:

"jsx": {
  "module": "Preact"
 },

Now you can build Preact components:

// Name.res
@jsx.component // or @react.component if you want
let make = (~name) => Preact.string(`Hello ${name}!`)

And you can use them just like normal with JSX:

let name = <Name name="Test" />

File level configuration

You can configure what JSX transform is used at the file level via @@jsxConfig, just like before. Like:

@@jsxConfig({module_: "Preact"})

Implementing a generic JSX transform module

Below is a full list of everything you need in a generic JSX transform module, including code comments to clarify. It's an example implementation of a Preact transform, so when doing this for other frameworks you'd of course adapt what you import from, and so on.

// Preact.res
/* Below is a number of aliases to the common `Jsx` module */
type element = Jsx.element

type component<'props> = Jsx.component<'props>

type componentLike<'props, 'return> = Jsx.componentLike<'props, 'return>

@module("preact")
external jsx: (component<'props>, 'props) => element = "jsx"

@module("preact")
external jsxKeyed: (component<'props>, 'props, ~key: string=?, @ignore unit) => element = "jsx"

@module("preact")
external jsxs: (component<'props>, 'props) => element = "jsxs"

@module("preact")
external jsxsKeyed: (component<'props>, 'props, ~key: string=?, @ignore unit) => element = "jsxs"

/* These identity functions and static values below are optional, but lets you move things easily to the `element` type. The only required thing to define though is `array`, which the JSX transform will output. */
external array: array<element> => element = "%identity"
@val external null: element = "null"

external float: float => element = "%identity"
external int: int => element = "%identity"
external string: string => element = "%identity"

/* These are needed for Fragment (<> </>) support */
type fragmentProps = {children?: element}

@module("preact") external jsxFragment: component<fragmentProps> = "Fragment"

/* The Elements module is the equivalent to the ReactDOM module in React. This holds things relevant to _lowercase_ JSX elements. */
module Elements = {
  /* Here you can control what props lowercase JSX elements should have. A base that the React JSX transform uses is provided via JsxDOM.domProps, but you can make this anything. The editor tooling will support autocompletion etc for your specific type when this ships. */
  type props = JsxDOM.domProps

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

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

  @module("preact")
  external jsxKeyed: (string, props, ~key: string=?, @ignore unit) => Jsx.element = "jsx"

  @module("preact")
  external jsxs: (string, props) => Jsx.element = "jsxs"

  @module("preact")
  external jsxsKeyed: (string, props, ~key: string=?, @ignore unit) => Jsx.element = "jsxs"

  external someElement: element => option<element> = "%identity"
}

As you can see, most of the things you'll want to implement will be copy paste from the above. But do note that everything needs to be there unless explicitly noted or the transform will fail.

Technical implementation

The technical implementation tries to minimize actual changes (since the React integration is still 1st class and the most refined one). The generic JSX transform is roughly implemented like this:

  • Rename relevant JSX transform parts to indicate that they're general rather than tied to React
  • No breaking changes to the JSX configuration
  • Minimal changes in how the JSX is run. It's all mostly the same as before

Outstanding questions

  • Does Gentype need to be adjusted? If so, how?
  • What do we do about React-specific things like ref, forwardRef and so on?
  • Should all of the mechanisms around key be available to all generic transforms? Is it needed?

Future

  • "Intrinsic elements" (read: The JSX module can define what lowercase elements are available, and what types each of those has). I have a few ideas here that I can detail later on.
  • Safer way of mixing JSX transforms in the same project? Right now, everything still needs to be aliased to Jsx.element, which means that there's currently no true separation between different JSX at the type level. You could use React.string (because that returns a Jsx.element) interchangeably with Preact.string (because that also needs to return a Jsx.element). This isn't ideal, but is pretty niche, so it's something we can solve later on.

Wrapping up

Excited to hear your feedback on this! This will obviously need a bunch of documentation to be useful.

Personally, I'm excited to ship this even if it's in a reduced form because I believe it can unlock a bit of innovation in ReScript. One example is I'm building res-x that uses JSX on the server with ReScript as regular HTML templating. The JSX integration there will be much smoother and idiomatic with this generic JSX transform.

@zth zth mentioned this pull request Jan 14, 2024
@mununki
Copy link
Member

mununki commented Jan 14, 2024

How about @jsxConfig({module: "Preact", version: 1, mode:...}) which is same as the fields in the rescript.json?

@zth
Copy link
Collaborator Author

zth commented Jan 14, 2024

How about @jsxConfig({module: "Preact", version: 1, mode:...}) which is same as the fields in the rescript.json?

Yes, that also works on this branch.

@cknitt
Copy link
Member

cknitt commented Jan 15, 2024

Great work!

I am wondering why it needs to be @preact.component though. This would mean that when I want to change my project from using React to using Preact I have to update all my components.

Can't one change the JSX config only and keep using @react.component (and maybe add an alias @jsx.component for @react.component if one does not want the attribute name to be framework-specific)?

@zth
Copy link
Collaborator Author

zth commented Jan 15, 2024

Great work!

I am wondering why it needs to be @preact.component though. This would mean that when I want to change my project from using React to using Preact I have to update all my components.

Can't one change the JSX config only and keep using @react.component (and maybe add an alias @jsx.component for @react.component if one does not want the attribute name to be framework-specific)?

We could make it configurable of course if this is a common approach. The Preact case is special in that people go between Preact and React. That's not the case of the rest of the potential integrations though. Solid JS, Vue, Hyperons, and more, are examples.

It does sound like the case you're describing is better handled by just redefining React and ReactDOM though, and sticking to the regular React transform, if you're after going back and forth between the two. And a global search/replace is really easy for any of the cases.

@tsnobip
Copy link
Contributor

tsnobip commented Jan 15, 2024

that would be my 2 cents too, if you can have multiple jsx transforms in rescript.json:

"jsx": {
  "preact": "Preact",
  "react": "MyCustomReact"
 },

then it would make sense to have different annotations @react.component, @preact.component, etc.

Otherwise I would stick to a generic @jsx.component

@zth
Copy link
Collaborator Author

zth commented Jan 15, 2024

Multiple transforms can't really be in the JSX config, you need to handle them in the local file. I don't have a strong opinion on what @component notation to use, so I'm fine with providing @react.component and @jsx.component as an alias to that. Having dedicated aliases depending on what config you put in module has both pros and cons. Pros are that it looks more first class (@solid.component vs @jsx.component), but downsides are perhaps that it unnecessarily complicates things and introduces a possible confusion.

@zth
Copy link
Collaborator Author

zth commented Jan 16, 2024

RFC updated to now not use dedicated @component like @preact.component. Rather, two notations are always available (and mean the same thing) - @react.component and @jsx.component.

@zth zth marked this pull request as ready for review January 18, 2024 13:11
@zth
Copy link
Collaborator Author

zth commented Jan 22, 2024

I updated the RFC again to remove the simplified @@jsxConfig feature, since I think it's less important without the explicit @someFramework.component syntax.

@zth zth force-pushed the generic-jsx-transform branch from 8deb5ec to 0e784ab Compare January 25, 2024 14:42
@zth zth changed the base branch from master to 11.0_release January 25, 2024 14:43
@zth zth force-pushed the generic-jsx-transform branch from 0e784ab to cdcd333 Compare January 25, 2024 14:43
Comment on lines +53 to +54
| Some i -> config.Jsx_common.version <- i);
(match getString ~key:"module_" fields with
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old mechanism didn't actually work, because module is a reserved keyword.

@zth
Copy link
Collaborator Author

zth commented Jan 25, 2024

I think we can move this forward in its current state. @cristianoc @mununki mind having a look at the code?

@zth zth requested review from mununki and cristianoc January 25, 2024 14:51
Copy link
Member

@mununki mununki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!

@zth zth force-pushed the generic-jsx-transform branch from 0bf8a9c to 8fd987c Compare January 29, 2024 09:04
@zth zth merged commit b88e74f into 11.0_release Jan 29, 2024
14 checks passed
@zth zth deleted the generic-jsx-transform branch January 29, 2024 09:28
@dkirchhof
Copy link

I'm checking this RFC out and have a problem with the jsx.module while using a namespace.

Example:

Custom jsx lib:

{
  "name": "reactor",
  "namespace": "reactor",
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "es6",
      "in-source": true
    }
  ],
  "suffix": ".mjs",
  "bs-dependencies": [
    "@rescript/core"
  ],
  "bsc-flags": [
    "-open RescriptCore"
  ],
  "jsx": {
    "module": "JSX"
  }
}

All the jsx stuff is implemented in src/JSX.res.

Demo project:

{
  "name": "reactor-demo",
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "es6",
      "in-source": true
    }
  ],
  "suffix": ".mjs",
  "bs-dependencies": [
    "@rescript/core",
    "reactor"
  ],
  "bsc-flags": [
    "-open RescriptCore"
  ],
  "jsx": {
    "version": 4,
    "mode": "automatic",
    "module": "Reactor.JSX"
  }
}

When I build the demo, I get this error: "The module or file Reactor.JSX can't be found."

Not totally sure if it was working before I added the namespace option. Before I outsourced the demo to another project, It was working fine.

@zth
Copy link
Collaborator Author

zth commented Feb 2, 2024

Good catch, I don't think we account for namespaces properly. Should be an easy fix.

What else have you found in terms of issues? Would be good to try to squeeze in fixes in the next rc of 11.1.

@dkirchhof
Copy link

Some documentation stuff (s. https://forum.rescript-lang.org/t/rfc-generic-jsx-transform/5006/7?u=dkirchhof)

Somewhere was written that you only need the jsx.module, but can't remember where I read about it. If I omit jsx.version I get an error ("the value can't be found").

@texastoland texastoland mentioned this pull request May 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants