Skip to content

[PoC] Rewrite nested JSX component paths to direct hoisted exports#8293

Open
fhammerschmidt wants to merge 12 commits intorescript-lang:masterfrom
fhammerschmidt:poc-jsx-component-nested-exports
Open

[PoC] Rewrite nested JSX component paths to direct hoisted exports#8293
fhammerschmidt wants to merge 12 commits intorescript-lang:masterfrom
fhammerschmidt:poc-jsx-component-nested-exports

Conversation

@fhammerschmidt
Copy link
Member

@fhammerschmidt fhammerschmidt commented Mar 12, 2026

ReScript currently lowers nested JSX component paths like <Sidebar.Provider> to cross-module member access in generated JS, for example Sidebar.Provider.make. That works in many cases, but it breaks in environments like Next.js Server Components, where nested component member access can end up as undefined at runtime even though direct top-level component bindings work.

This PR changes JSX v4 lowering so nested component modules also emit a hidden direct export for their underlying component function, and transformed JSX runtime calls use that direct export instead of .make member access when available. That keeps existing source syntax and normal exports intact while producing a safer JS shape for server-
component toolchains.

Example before:

JsxRuntime.jsx(Sidebar$Foo.Provider.make, ...)

Example after:

JsxRuntime.jsx(Sidebar$Foo.Sidebar$Foo$Provider, ...)

Justification

The change crosses several compiler layers because the information needed for the rewrite starts in the JSX transform but
is only actionable during JS lowering.

<Sidebar.Provider> is initially just JSX syntax. To compile it to a direct export safely, the compiler needs to:

  • recognize that the tag came from a JSX component path, not ordinary value access,
  • preserve that distinction while translating the path into lambda,
  • emit hidden direct exports for nested component modules,
  • and finally rewrite transformed JSX runtime calls to use those exports.

That is why the PR touches:

  • JSX transform files, to mark nested JSX component paths and emit hoisted direct exports,
  • lambda translation files, to preserve enough path information through lowering,
  • and JS compilation files, to rewrite only transformed JSX callsites without affecting ordinary .make access.

So the surface behavior change is small, but the data needed to do it correctly spans multiple stages of the compiler pipeline.

Either way I marked it as a PoC since it might not be the best way to do it.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 12, 2026

Open in StackBlitz

rescript

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

@rescript/darwin-arm64

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

@rescript/darwin-x64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 3292459

Copy link
Member

@tsnobip tsnobip left a comment

Choose a reason for hiding this comment

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

unfortunately I can't test this PR yet because it messes up imports, let's say I use .res.mjs as output suffix, it should generate import like this:

import * as Button$RescriptShadcn from "../registry/base/ui/Button.res.mjs";
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";

but instead, it doesn't use .js for runtime but .mjs:

import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.mjs";

weird that it doesn't get detected by some tests btw!

@tsnobip
Copy link
Member

tsnobip commented Mar 13, 2026

I found another bug around namespace.
If namespace is disabled, it will generate the following correct code:

JsxRuntime.jsx(Sidebar.Sidebar$Provider, ...)

but if I enable namespace, it generates this:

JsxRuntime.jsx(Sidebar$RescriptShadcn.Sidebar$RescriptShadcn$Provider, ...)

while it should be:

JsxRuntime.jsx(Sidebar$RescriptShadcn.Sidebar$Provider, ...)

the sub component should not be namespaced.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR is a PoC change to the ReScript JSX v4 pipeline so nested JSX component paths (e.g. <Sidebar.Provider>) lower to direct hoisted exports instead of cross-module member access (.make), improving runtime safety in environments like Next.js Server Components.

Changes:

  • Mark JSX-generated component path identifiers with a new internal attribute (res.jsxComponentPath) and preserve that through typed/lambda lowering.
  • Emit hidden hoisted exports (and $jsx marker bindings) for nested component modules, and rewrite transformed JSX runtime calls to target those hoisted exports.
  • Update/extend compiler test fixtures and add a build test asserting the new JS shape for nested JSX members.

Reviewed changes

Copilot reviewed 51 out of 51 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/tests/src/jsxv4_newtype.mjs Updates expected JS output to include new $jsx markers and hidden exports.
tests/tests/src/jsx_optional_props_test.mjs Updates expected JS output to include $jsx marker and hidden export for component.
tests/tests/src/async_jsx.mjs Updates expected JS output to include $jsx markers and hidden exports for nested components.
tests/tests/src/alias_default_value_test.mjs Updates expected JS output to include $jsx markers and hidden exports for nested components.
tests/syntax_tests/data/ppx/react/expected/v4.res.txt Updates PPX expected output to include hoisted bindings/markers for nested modules.
tests/syntax_tests/data/ppx/react/expected/uncurriedProps.res.txt Updates PPX output to tag component paths and add hoisted bindings/markers.
tests/syntax_tests/data/ppx/react/expected/typeConstraint.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/topLevel.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/spreadProps.res.txt Updates expected output to annotate component-path arguments.
tests/syntax_tests/data/ppx/react/expected/sharedProps.res.txt Adds hoisted binding/marker expectations for nested components.
tests/syntax_tests/data/ppx/react/expected/returnConstraint.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/optionalKeyType.res.txt Updates expected output to annotate component-path arguments.
tests/syntax_tests/data/ppx/react/expected/optimizeAutomaticMode.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/noPropsWithKey.res.txt Updates expected output to annotate component-path arguments and adds hoisted bindings/markers.
tests/syntax_tests/data/ppx/react/expected/newtype.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/nested.res.txt Updates expected output for nested-module component wrappers and component-path annotation.
tests/syntax_tests/data/ppx/react/expected/mangleKeyword.res.txt Updates expected output to annotate component-path arguments and adds hoisted binding/marker.
tests/syntax_tests/data/ppx/react/expected/interface.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/fragment.res.txt Updates expected output to annotate component-path arguments.
tests/syntax_tests/data/ppx/react/expected/forwardRef.res.txt Updates expected output to annotate component-path arguments and adds hoisted bindings/markers.
tests/syntax_tests/data/ppx/react/expected/fileLevelConfig.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/externalWithCustomName.res.txt Updates expected output to annotate component-path arguments for external component identifiers.
tests/syntax_tests/data/ppx/react/expected/defaultValueProp.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/asyncAwait.res.txt Adds hoisted binding/marker expectations.
tests/syntax_tests/data/ppx/react/expected/aliasProps.res.txt Updates expected output to annotate component-path arguments and adds hoisted bindings/markers.
tests/gentype_tests/typescript-react-example/src/Hooks.res.js Updates expected JS output to include $jsx markers and hidden exports.
tests/build_tests/rsc_nested_jsx_members/src/Sidebar.res New build-test source defining nested component modules (Provider, Inset).
tests/build_tests/rsc_nested_jsx_members/src/React.res New minimal React/JSX runtime bindings used by the build test.
tests/build_tests/rsc_nested_jsx_members/src/MainLayout.res New build-test source using <Sidebar.Provider> nested JSX member syntax.
tests/build_tests/rsc_nested_jsx_members/rescript.json New build-test config enabling JSX v4 + namespacing in-source ES modules.
tests/build_tests/rsc_nested_jsx_members/input.js New build-test assertions verifying rewritten JSX tag shape and exports.
tests/analysis_tests/tests/src/expected/JsxV4.res.txt Updates analysis expected output to include hoisted bindings/markers in signatures.
tests/analysis_tests/tests/src/expected/CreateInterface.res.txt Updates analysis expected output to include hoisted binding/marker in signatures.
rewatch/tests/watch/06-watch-missing-source-folder.sh Improves watcher test robustness (wait for shutdown + rebuild/error handling).
compiler/syntax/src/jsx_v4.ml Adds component-path attribute emission and hoisted export generation for nested make.
compiler/syntax/src/jsx_ppx.ml Tracks/appends hoisted structure items during JSX v4 PPX rewriting.
compiler/syntax/src/jsx_common.ml Extends JSX config to store hoisted structure items.
compiler/ml/translmod.ml Extends module field debug info to include jsx_component flag.
compiler/ml/translcore.ml Routes annotated component-path identifiers through transl_jsx_value_path.
compiler/ml/printlambda.ml Updates printer for extended module field debug info.
compiler/ml/lambda.mli Extends Fld_module debug info, adds transl_jsx_value_path API.
compiler/ml/lambda.ml Implements transl_jsx_value_path and Fld_module constructor.
compiler/core/lam_print.ml Updates lambda pretty-printing for extended Fld_module.
compiler/core/lam_pass_remove_alias.ml Updates alias simplification matching for extended Fld_module.
compiler/core/lam_compile.ml Rewrites transformed JSX call tags to use hoisted hidden exports when possible.
compiler/core/lam_compat.mli Mirrors Fld_module debug info change in compatibility layer.
compiler/core/lam_compat.ml Mirrors Fld_module debug info change in compatibility layer.
compiler/core/lam_arity_analysis.ml Updates arity analysis matching for extended Fld_module.
compiler/core/lam_analysis.ml Updates effect analysis matching for extended Fld_module.
compiler/core/lam.ml Updates module-field primitive construction to supply jsx_component=false.
compiler/core/js_of_lam_block.ml Updates JS lowering of module field access to ignore the new flag.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PoC adjusts the JSX v4 compilation pipeline to avoid generating nested component member access (e.g. .Provider.make) at transformed JSX callsites by introducing hoisted “direct export” bindings for nested component modules and rewriting generated JS to use those direct exports when available (to better support environments like RSC/Next.js).

Changes:

  • Emit hoisted top-level bindings (plus $jsx marker exports) for nested module components so they can be referenced directly from generated JS.
  • Thread a @res.jsxComponentPath marker through the AST → lambda translation so JS lowering can distinguish JSX tag paths from ordinary .make access.
  • Rewrite transformed JSX runtime calls during JS compilation to use the hoisted export when the $jsx marker indicates it exists; add/adjust compiler tests and add a new build test reproducer.

Reviewed changes

Copilot reviewed 58 out of 58 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/tests/src/jsxv4_newtype.mjs Updates expected JS exports to include hoisted component bindings + $jsx markers.
tests/tests/src/jsx_optional_props_test.mjs Updates expected JS exports for hoisted component binding + $jsx marker.
tests/tests/src/async_jsx.mjs Updates expected JS exports for hoisted component bindings + $jsx markers.
tests/tests/src/alias_default_value_test.mjs Updates expected JS exports for hoisted component bindings + $jsx markers.
tests/tests/src/ExternalArity.mjs Updates expected JS exports for nested external component hoist + $jsx marker.
tests/syntax_tests/data/ppx/react/expected/v4.res.txt Updates expected PPX output to include hoisted bindings + $jsx markers.
tests/syntax_tests/data/ppx/react/expected/uncurriedProps.res.txt Marks React.jsx tag arg with @res.jsxComponentPath; adds hoisted bindings + $jsx markers.
tests/syntax_tests/data/ppx/react/expected/typeConstraint.res.txt Adds hoisted binding + $jsx marker in expected output.
tests/syntax_tests/data/ppx/react/expected/topLevel.res.txt Adds hoisted binding + $jsx marker in expected output.
tests/syntax_tests/data/ppx/react/expected/spreadProps.res.txt Marks component path arg with @res.jsxComponentPath in expected output.
tests/syntax_tests/data/ppx/react/expected/sharedProps.res.txt Adds hoisted bindings + $jsx markers in expected output.
tests/syntax_tests/data/ppx/react/expected/returnConstraint.res.txt Adds hoisted bindings + $jsx markers in expected output.
tests/syntax_tests/data/ppx/react/expected/optionalKeyType.res.txt Marks component path arg with @res.jsxComponentPath in expected output.
tests/syntax_tests/data/ppx/react/expected/optimizeAutomaticMode.res.txt Adds hoisted binding + $jsx marker in expected output.
tests/syntax_tests/data/ppx/react/expected/noPropsWithKey.res.txt Marks component path arg with @res.jsxComponentPath; adds hoisted bindings + $jsx markers.
tests/syntax_tests/data/ppx/react/expected/newtype.res.txt Adds hoisted binding + $jsx marker in expected output.
tests/syntax_tests/data/ppx/react/expected/nested.res.txt Adjusts nested module naming in expected output; adds hoisted bindings + $jsx markers; marks component path.
tests/syntax_tests/data/ppx/react/expected/mangleKeyword.res.txt Marks component path arg with @res.jsxComponentPath; adds hoisted bindings + $jsx markers.
tests/syntax_tests/data/ppx/react/expected/interface.res.txt Adds hoisted bindings + $jsx markers in expected output.
tests/syntax_tests/data/ppx/react/expected/fragment.res.txt Marks component path arg with @res.jsxComponentPath in expected output.
tests/syntax_tests/data/ppx/react/expected/forwardRef.res.txt Marks component path arg with @res.jsxComponentPath; adds hoisted bindings + $jsx markers.
tests/syntax_tests/data/ppx/react/expected/fileLevelConfig.res.txt Adds hoisted binding + $jsx marker in expected output.
tests/syntax_tests/data/ppx/react/expected/externalWithTypeVariables.res.txt Adds hoisted binding + $jsx marker in expected output for external component module.
tests/syntax_tests/data/ppx/react/expected/externalWithRef.res.txt Adds hoisted binding + $jsx marker in expected output for external component module.
tests/syntax_tests/data/ppx/react/expected/externalWithCustomName.res.txt Marks component path arg with @res.jsxComponentPath for custom external component name.
tests/syntax_tests/data/ppx/react/expected/defaultValueProp.res.txt Adds hoisted bindings + $jsx markers in expected output.
tests/syntax_tests/data/ppx/react/expected/asyncAwait.res.txt Adds hoisted bindings + $jsx markers in expected output.
tests/syntax_tests/data/ppx/react/expected/aliasProps.res.txt Marks component path arg with @res.jsxComponentPath; adds hoisted bindings + $jsx markers.
tests/gentype_tests/typescript-react-example/src/Hooks.res.js Updates expected JS exports to include hoisted component bindings + $jsx markers.
tests/build_tests/rsc_nested_jsx_members/src/SidebarExternalImpl.js Adds JS implementation for an external Provider component used in the build test.
tests/build_tests/rsc_nested_jsx_members/src/SidebarExternal.res Adds nested external component module used to reproduce/validate the rewrite.
tests/build_tests/rsc_nested_jsx_members/src/Sidebar.res Adds nested component modules (Provider, Inset) used to reproduce/validate the rewrite.
tests/build_tests/rsc_nested_jsx_members/src/React.res Adds minimal React/jsx-runtime bindings for the build test fixture.
tests/build_tests/rsc_nested_jsx_members/src/MainLayoutExternal.res Uses <SidebarExternal.Provider> to validate external nested module rewrite.
tests/build_tests/rsc_nested_jsx_members/src/MainLayout.res Uses <Sidebar.Provider> to validate nested module rewrite.
tests/build_tests/rsc_nested_jsx_members/rescript.json Adds a namespaced, ESModule build-test project configuration for the fixture.
tests/build_tests/rsc_nested_jsx_members/input.js Adds assertions ensuring generated JS uses hoisted exports (and avoids .Provider.make).
tests/build_tests/react_ppx/src/recursive_component_test.res.js Updates expected JS exports to include hoisted binding + $jsx marker.
tests/analysis_tests/tests/src/expected/JsxV4.res.txt Updates analysis expected output for new hoisted binding declarations + $jsx marker types.
tests/analysis_tests/tests/src/expected/CreateInterface.res.txt Updates analysis expected output for new hoisted binding declarations + $jsx marker types.
rewatch/tests/watch/06-watch-missing-source-folder.sh Makes watcher test more robust by waiting for lock cleanup and handling slow restores.
compiler/syntax/src/jsx_v4.ml Implements hoisting of nested component exports; adds @res.jsxComponentPath tag marker; tracks nested module context.
compiler/syntax/src/jsx_ppx.ml Resets/collects hoisted structure items at top level and appends them after normal items for JSX v4.
compiler/syntax/src/jsx_common.ml Extends JSX config with hoisted_structure_items accumulator.
compiler/ml/translmod.ml Updates module field debug info construction to include jsx_component=false.
compiler/ml/translcore.ml Uses @res.jsxComponentPath to translate JSX tag paths via a dedicated lambda path translation.
compiler/ml/printlambda.ml Updates printing for Fld_module to account for new record field.
compiler/ml/lambda.mli Extends Fld_module debug info with jsx_component: bool; exposes transl_jsx_value_path.
compiler/ml/lambda.ml Implements transl_jsx_path that marks final .make field access; adds fld_module helper.
compiler/core/lam_print.ml Updates lam printer pattern matches for extended Fld_module.
compiler/core/lam_pass_remove_alias.ml Updates alias simplifier pattern matches for extended Fld_module.
compiler/core/lam_compile.ml Adds logic to detect/rewrite nested JSX component tags to hoisted exports when $jsx marker exists.
compiler/core/lam_compat.mli Updates compat type for extended Fld_module.
compiler/core/lam_compat.ml Updates compat type/matching for extended Fld_module.
compiler/core/lam_arity_analysis.ml Updates arity analysis pattern matches for extended Fld_module.
compiler/core/lam_analysis.ml Updates side-effect analysis pattern matches for extended Fld_module.
compiler/core/lam.ml Ensures synthesized module-field accesses default jsx_component=false.
compiler/core/js_of_lam_block.ml Updates module-field codegen to ignore jsx_component flag.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +857 to 861
let () =
maybe_hoist_nested_make_component ~config ~empty_loc ~full_module_name
fn_name
in
(Some props_record_type, binding, new_binding))
Comment on lines 1690 to +1700
(Ext_list.append block args_code, b :: fn_code)
| {value = None} -> assert false)
in
let args =
if appinfo.ap_transformed_jsx then
match (appinfo.ap_args, args) with
| jsx_tag :: _, jsx_expr :: rest_args ->
rewrite_nested_jsx_component_expr jsx_tag jsx_expr :: rest_args
| _ -> args
else args
in
Comment on lines 1791 to 1793
| {
primitive = Pfield (_, fld_info);
args = [Lglobal_module (id, dynamic_import)];
@tsnobip
Copy link
Member

tsnobip commented Mar 13, 2026

@fhammerschmidt the namespace issue seems to be fixed but not the runtime extension.

@fhammerschmidt
Copy link
Member Author

Yeah it looks like we need a good repro for this.

Codex:

So in this repo I cannot reproduce the exact .js + .mjs mixed-runtime bug from the PR comment, because the local runtime here already resolves to .mjs. What this fixture does lock in is:

  • local module import stays .res.mjs
  • runtime import stays whatever the compiler chose for runtime
  • no duplicate runtime imports with different extensions are introduced by the nested JSX rewrite

@fhammerschmidt
Copy link
Member Author

@cknitt what's the path forward here since you implemented
#8207

unfortunately I can't test this PR yet because it messes up imports, let's say I use .res.mjs as output suffix, it should generate import like this:

import * as Button$RescriptShadcn from "../registry/base/ui/Button.res.mjs";
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";

but instead, it doesn't use .js for runtime but .mjs:

import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.mjs";

weird that it doesn't get detected by some tests btw!

@tsnobip
Copy link
Member

tsnobip commented Mar 15, 2026

@fhammerschmidt my bad, after deleting next cache, node_modules, etc and cleaning mismatch versions of rescript and @rescript/runtime, it seems like the import works, my bad! I'll try again once the PR builds and gets published.

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