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

JSX preserve mode generates invalid JSX for external component bindings

Open han-tyumi opened this issue 3 weeks ago • 1 comments

Thank you for filing! Check list:

  • [x] Is it a bug? Usage questions should often be asked in the forum instead.
  • [x] Concise, focused, friendly issue title & description.
  • [x] A minimal, reproducible example.
  • [ ] OS and browser versions, if relevant.
  • [ ] Is it already fixed in master? Instructions

Summary

When using the generic JSX transform with "preserve": true, external component bindings generate invalid JSX syntax like <prim => Module.Component(prim)> instead of valid JSX.

ReScript Version

12.0.0

Minimal Reproduction

https://github.com/han-tyumi/rescript-jsx-preserve-bug

git clone https://github.com/han-tyumi/rescript-jsx-preserve-bug.git
cd rescript-jsx-preserve-bug
npm install
npx rescript build
cat Test.jsx
Files

rescript.json:

{
  "name": "preserve-bug",
  "sources": ["."],
  "package-specs": {
    "module": "esmodule",
    "in-source": true
  },
  "suffix": ".jsx",
  "jsx": {
    "module": "Preact",
    "preserve": true
  }
}

Preact.res (minimal bindings):

type element
type component<'props> = 'props => element

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

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

type fragmentProps = {children?: element}

@module("preact/jsx-runtime")
external jsxFragment: component<fragmentProps> = "Fragment"

type domProps = {children?: element}

module Elements = {
  external someElement: element => option<element> = "%identity"

  @module("preact/jsx-runtime")
  external jsx: (string, domProps) => element = "jsx"

  @module("preact/jsx-runtime")
  external jsxs: (string, domProps) => element = "jsxs"
}

Test.res:

// Component module pattern with external make
module Head = {
  type props = {children?: Preact.element}
  @module("some-lib")
  external make: props => Preact.element = "Head"
}

// Using the component
let test = <Head> <div /> </Head>

Expected Output

Valid JSX that can be processed by standard JSX transformers:

let test = <SomeLib.Head>
  <div />
</SomeLib.Head>;

Actual Output

Invalid JSX with arrow function syntax:

let test = <prim => SomeLib.Head(prim)>
  {Primitive_option.some(<div />)}
</prim => SomeLib.Head(prim)>;

Notes

  • Lowercase DOM elements work correctly in preserve mode (e.g., <div> stays as <div>)
  • Internal component modules (with ReScript-defined make functions) work correctly (e.g., <MyComponent.make>)
  • External component bindings produce the invalid arrow function syntax shown above

This prevents using preserve mode with frameworks like Preact/Fresh where you need to bind to external components.

han-tyumi avatar Dec 01 '25 13:12 han-tyumi

I think the root cause is that we still have not made Jsx.component<'a> an abstract type. (We tried, but hit a roadblock in #6304.)

The workaround is to use React.component<'a>/Preact.component<a> instead of props => React.element / props => Preact.element.

This works fine:

module Preact = {
  type element = Jsx.element
  type component<'props> = Jsx.component<'props>

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

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

  type fragmentProps = {children?: element}

  @module("preact/jsx-runtime")
  external jsxFragment: component<fragmentProps> = "Fragment"

  type domProps = {children?: element}

  module Elements = {
    external someElement: element => option<element> = "%identity"

    @module("preact/jsx-runtime")
    external jsx: (string, domProps) => element = "jsx"

    @module("preact/jsx-runtime")
    external jsxs: (string, domProps) => element = "jsxs"
  }
}

// Component module pattern with external make
module Head = {
  type props = {children?: Preact.element}

  @module("some-lib")
  external make: Preact.component<props> = "Head"
}

// Using the component - this produces invalid JSX
let test =
  <Head>
    <div />
  </Head>

cknitt avatar Dec 06 '25 20:12 cknitt