Skip to content

Make Jsx.component abstract#8390

Open
cknitt wants to merge 15 commits intomasterfrom
abstract-jsx-component
Open

Make Jsx.component abstract#8390
cknitt wants to merge 15 commits intomasterfrom
abstract-jsx-component

Conversation

@cknitt
Copy link
Copy Markdown
Member

@cknitt cknitt commented Apr 25, 2026

Summary

This PR makes Jsx.component an abstract type instead of an alias for a function type.

Previously, components were structurally just functions:

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

That meant any function with the right shape could be used as a React component. This PR changes the public component type to be abstract:

type component<-'props>

and introduces a new explicit zero-cost component coercion primitive:

external component: componentLike<'props, element> => component<'props> = "%component_identity"

The JSX PPX now emits React.component(...) around generated component wrappers, so component definitions still compile to the same JavaScript while getting the abstract component type in ReScript.

This PR also improves the type error for JSX tags that resolve to plain functions. Instead of reporting a generic function-argument mismatch, the compiler now points at the JSX tag and explains that JSX component positions require a Jsx.component, with suggestions for @react.component, @react.componentWithProps, or an explicit React.component(...) coercion.

Why

React components are not always plain functions. React.memo, React.forwardRef, React.lazy, and framework-specific wrappers can all produce component values that React accepts, but that should not be called directly as normal functions.

With component<'props> defined as 'props => element, ReScript encouraged that wrong mental model: any component could also be treated as a function. Code like this typechecked even though it is not a valid operation for every React component:

let renderDirectly = (component: React.component<props>, props) => component(props)

That can fail at runtime when component is not a function. For example, a memoized or lazy component may be represented as an object that React knows how to render, but JavaScript will throw when user code tries to call it directly.

Making component abstract gives us a single component type that describes values React can render, without pretending those values are structurally just functions. Users can still build components through JSX and the React APIs, but plain functions no longer become components just because their argument and return types happen to match.

The props parameter is contravariant because components consume props: a component that accepts broader props can safely be used where a component accepting narrower props is expected.

Example

A component definition still looks the same:

@react.component
let make = (~name) => React.string(name)

The PPX now conceptually lowers it to:

type props<'name> = {name: 'name}

let make = ({name, _}: props<_>): React.element =>
  React.string(name)

let make = React.component({
  let "FileName" = props => make(props)
  "FileName"
})

The generated JavaScript remains a plain function. %component_identity is new in this PR, but it lowers to the same Pidentity Lambda primitive as %identity, so it has no runtime cost.

Breaking Change

This deliberately tightens the type-system surface around React components. React.component<'props> is no longer structurally the same type as 'props => React.element, so code that relied on that structural equivalence will need to be updated.

Code that treats a component as a normal function will stop typechecking:

let renderDirectly = (component: React.component<props>, props) =>
  component(props)

That code should be rewritten to render through React instead:

let render = (component: React.component<props>, props) =>
  React.createElement(component, props)

Code that passes a plain function to a React API that expects a component also needs an explicit zero-cost coercion:

let render = props => React.string(props.name)

React.createElement(React.component(render), {name: "Ada"})

The same applies when using JSX syntax with a plain props-record function that has not been annotated as a component:

module A = {
  type props = {value: string}
  let make = (props: props) => <div> {React.string(props.value)} </div>
}

let _ = <A value="hello" />

This now reports a JSX-specific error on A, because the tag resolves to a plain function rather than a Jsx.component. For a props-record component like this, the intended fix is @react.componentWithProps. For labeled props, use @react.component.

Recursive component bodies have the same requirement. The recursive binding remains the callable implementation function inside its own body, so component-position uses still need an explicit coercion:

@react.component
let rec make = (~foo) =>
  React.createElement(React.component(make), {foo})

The JSX PPX inserts React.component(...) at the component definition site, around the wrapper function it generates for @react.component and @react.componentWithProps. It does not rewrite user-authored call sites such as React.createElement(make, props), React.jsx(make, props), React.jsxs(make, props), or custom runtime calls inside arbitrary function bodies. If user code owns the component-position call, user code owns the React.component(...) coercion.

Custom JSX runtimes that alias Jsx.component must also expose the coercion expected by the PPX:

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

external component: componentLike<'props, element> => component<'props> = "%component_identity"

The GenType React fixture patches its npm @rescript/react@0.14.0 dependency to expose the same coercion, because that package version predates %component_identity.

For ReScript 13, we will need to publish a matching new @rescript/react release that defines React.component with %component_identity. Older @rescript/react versions still define it with %identity, so projects upgrading to ReScript 13 need the corresponding React bindings update as well.

New Component Coercion Primitive

This PR adds a new compiler primitive, %component_identity, specifically for component coercions. It compiles like %identity by lowering to Pidentity, but the typechecker gives it one extra rule: fully applied %component_identity calls are non-expansive when their arguments are non-expansive.

This preserves generalization for cases like:

@react.component
let make = (~x) =>
  switch x {
  | #a => React.string("A")
  | #b => React.string("B")
  | _ => React.string("other")
  }

The PPX emits a React.component(...) wrapper for make. Without the %component_identity non-expansiveness rule, that wrapper would prevent the inferred polymorphic variant props from generalizing, and the component binding would fail to compile with non-generalized weak type variables.

This is intentionally not a general change to %identity. Plain %identity externals keep their previous value-restriction behavior, so unrelated casts such as Obj.magic or other unsafe identity-shaped externals do not start generalizing differently. %component_identity exists to make the React component coercion explicit enough for the typechecker to recognize without changing the meaning of every existing %identity external.

Tests

Added and updated coverage for:

  • abstract component PPX output
  • polymorphic props generalization through React.component
  • %component_identity generalization
  • a regression showing ordinary %identity applications do not receive the new generalization behavior
  • explicit React.component(...) coercions for recursive component-position calls
  • an error fixture for an unwrapped recursive component passed to React.createElement
  • a JSX-specific error fixture for a plain function used as a JSX tag
  • patched npm @rescript/react@0.14.0 coverage in the GenType React fixture
  • analysis/gentype/super-error fixtures affected by the new component type

@cknitt cknitt marked this pull request as draft April 25, 2026 19:28
@cknitt
Copy link
Copy Markdown
Member Author

cknitt commented Apr 25, 2026

@codex review

@cknitt cknitt marked this pull request as ready for review April 25, 2026 20:28
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 25, 2026

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8390

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8390

@rescript/darwin-x64

npm i https://pkg.pr.new/@rescript/darwin-x64@8390

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8390

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8390

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8390

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8390

commit: 88c4605

@cknitt cknitt requested review from cristianoc and mununki April 25, 2026 20:41
@cknitt cknitt force-pushed the abstract-jsx-component branch from 4d31e9d to 765b113 Compare April 26, 2026 06:01
@cknitt cknitt requested a review from zth April 26, 2026 06:53
Copy link
Copy Markdown
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.

Adding a component-specific identity primitive to allow generalization for the props type of component is a nice improvement. Great idea!

Comment on lines 18 to +26
type componentLike<'props, 'return> = 'props => 'return
type component<'props> = componentLike<'props, element>

/* Components consume props. If one component can accept broader props, it can
safely stand in for a component that only needs narrower props, just like a
function argument type. That makes the props parameter contravariant. */
type component<-'props>

/* this function exists to prepare for making `component` abstract */
external component: componentLike<'props, element> => component<'props> = "%identity"
external component: componentLike<'props, element> => component<'props> = "%component_identity"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I’m in favor of keeping the componentLike type for now to reduce the breaking surface. That said, for a cleaner API long-term, I wonder if we should eventually move away from componentLike and define component directly as if possible:

  external component: ('props => element) => component<'props> = "%component_identity"

What do you think?

@cristianoc
Copy link
Copy Markdown
Collaborator

What was the issue when this was tried in the past?
I kind of remember there was some blocker, but not what it was.

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.

3 participants