Skip to content

Export nested make values flat, derived from the signature - #8904

Open
cknitt wants to merge 4 commits into
masterfrom
jsx-flat-exports
Open

cknitt wants to merge 4 commits into
masterfrom
jsx-flat-exports

Conversation

@cknitt

@cknitt cknitt commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Closes #8901. Alternative to #8886 and #8293; removes @res.hoistedFunction (#8402).

Problem

React Server Components only pass a client module's top-level exports to the client. Nested components such as <Sidebar.Provider /> compiled to Sidebar.Provider.make, a read through the module object, which fails on a client reference.

#8402/#8886 add flat exports depending on the producer's implementation, and record them in the .cmj only. With a .resi, an implementation-only edit can drop an export while the .cmi stays byte-identical, so dependents are not rebuilt and crash at runtime (see the review of #8886).

Change

Every make in a nested module of a file's final signature (the .resi's, if there is one) is also exported as A$B$make, and reads of Lib.A.B.make from other modules compile to Lib.A$B$make.

// Sidebar.res
module Provider = {
  @react.component
  let make = (~children) => children
}
// Page.res
let page = <Sidebar.Provider> {React.null} </Sidebar.Provider>
// Sidebar.res.js
let Provider$make = Sidebar$Provider;
export { Provider, Provider$make }
// Page.res.js
JsxRuntime.jsx(Sidebar.Provider$make, {children: null})
  • Producer and consumer both decide from the signature alone: no .cmj table, no marker attribute, no JSX transform changes. The export set changes exactly when the .cmi does.
  • The alias names the binding the module field holds, so it is a plain let A$B$make = make that bundlers drop when unused. It falls back to a property read only when the field holds no plain binding (Some(make), a functor result, an awaited module).
  • Memo after the definition, module rec, relative externals, include, module aliases and functor applications all get flat exports. Components hidden by the .resi don't.
  • import(Lib.A.B.make) imports the flat export, and reads below module D = await Lib use the flat names (D.A$B$make).
  • A module re-exported from another file (module Provider = Sidebar.Provider) re-exports that file's flat export (let Provider$make = Sidebar.Provider$make).
  • A flat name already used by a top-level binding is a compile error.
  • @res.hoistedFunction, its .cmj table and lookups are removed; a leftover use reports warning 101. The .cmi magic number is bumped.

Breaking

  • @res.hoistedFunction is gone (it shipped only in v13.0.0-alpha.6). Nested functions other than make are no longer exported flat.
  • Other modules read Lib.A$make instead of Lib.A.make, so test spies or mocks that replace make on the module object no longer intercept those reads.

Bundle size

Bundling a producer with 200 nested components through esbuild, Rollup, Rolldown and webpack gives the same or slightly smaller bundles than master; unused flat exports are dropped.

Tests

  • tests/build_tests/react_ppx/src/nested_component_{producer,consumer}.res (with a .resi, namespaced): every nested JSX tag reads a flat export; the hidden component has none.
  • tests/tests/src/flat_make_export*.res (replacing hoisted_function_attr*): nested, deep, recursive, coerced, rebound, functor-applied, included and aliased make values across modules.
  • tests/build_tests/react_ppx/src/nested_component_reexport.res (with a .resi) and the awaited reads in nested_component_consumer.res: re-exports and awaited namespaces go through flat exports.
  • tests/tests/src/flat_make_export_reexport.res and the awaited case in flat_make_export_test.res: the same at runtime.
  • tests/tests/src/import2.res: dynamic import of a nested make.
  • super_errors/flat_make_export_collision.res, and warning_101_bs_unused_attribute.res for a leftover @res.hoistedFunction.

The other changed JS snapshots only gain flat exports or read them.

🤖 Generated with Claude Code

https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh

@cknitt

cknitt commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T12:12:59.077851Z 63a47ff Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codecov

codecov Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.19%. Comparing base (38ba55a) to head (63a47ff).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
compiler/core/lam_compile_main.ml 91.80% 5 Missing ⚠️
compiler/ml/translmod.ml 96.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8904      +/-   ##
==========================================
+ Coverage   81.14%   81.19%   +0.05%     
==========================================
  Files         456      456              
  Lines       63760    63742      -18     
==========================================
+ Hits        51738    51756      +18     
+ Misses      12022    11986      -36     
Files with missing lines Coverage Δ
compiler/core/js_cmj_format.ml 92.85% <ø> (-0.48%) ⬇️
compiler/core/js_implementation.ml 83.89% <100.00%> (-0.14%) ⬇️
compiler/core/lam_compile.ml 90.20% <100.00%> (+0.17%) ⬆️
compiler/core/lam_compile_env.ml 85.71% <ø> (-0.83%) ⬇️
compiler/core/lam_compile_primitive.ml 81.13% <100.00%> (ø)
compiler/core/lam_stats.ml 85.71% <ø> (ø)
compiler/core/lam_stats_export.ml 91.11% <100.00%> (ø)
compiler/ext/config.ml 100.00% <ø> (ø)
compiler/frontend/bs_builtin_ppx.ml 90.36% <ø> (-0.25%) ⬇️
compiler/ml/lambda.ml 67.87% <ø> (ø)
... and 6 more

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93962f9715

ℹ️ 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".

Comment thread compiler/core/lam_compile_main.ml
@pkg-pr-new

pkg-pr-new Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8904

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 63a47ff

@github-actions

Copy link
Copy Markdown

@cknitt
cknitt marked this pull request as ready for review October 19, 2026 14:48
cknitt added a commit that referenced this pull request Oct 19, 2026
A flat `A$B$make` export now only clashes with another export. A binding
hidden by the interface may have the same JS name: the printer renames the
local and exports the alias under the flat name.

Example (rescript):
    // Lib.resi exposes A.B.make and both, but not \"A$B$make"
    module A = {
      module B = {
        let make = () => 1
      }
    }
    let \"A$B$make" = () => Math.random() > 2. ? 0 : 2
    let both = () => A.B.make() + \"A$B$make"()

After (js):
    function A$B$make() { ... }
    let A$B$make$1 = make;
    export { A, both, A$B$make$1 as A$B$make }

Source: tests/tests/src/flat_make_export_hidden.mjs in this diff; the
previous commit rejected this file.

Review: Codex finding on #8904, lam_compile_main.ml:186.

Area: lambda tests
Output: changed tests/tests/src/flat_make_export_*.mjs only, for the new test
Formatting: unchanged
Formats: none
PPX: unchanged
Editor: unchanged
Tests: +tests/tests/src/flat_make_export_hidden.res
 ~tests/tests/src/flat_make_export_test.res
 ~tests/build_tests/super_errors/expected/flat_make_export_collision.res.expected
PR: #8904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: f4fbbe37e9

ℹ️ 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".

Comment thread compiler/core/lam_compile_main.mli Outdated
cknitt added a commit that referenced this pull request Oct 19, 2026
The paths are a field of `Translmod.implementation`, built from the final
signature by `transl_implementation`, and `Lam_compile_main.compile` takes
the whole record. Each step of a path is a `{pos; name}` record instead of
an `(int * string)` pair. Consumer reads and dynamic imports share
`Translmod.flat_make_name`, and the aliases are plain `(ident, value)`
pairs.

Review: fhammerschmidt on #8904, lam_compile_main.mli:33, asked for a
labeled argument or a named type instead of `(int * string) list list`.

Area: lambda codegen playground tests
Output: unchanged, node scripts/test.js -all rebuilds every test project
 with no generated file changing
Formatting: unchanged
Formats: none
PPX: unchanged
Editor: unchanged
Tests: none, because the change is a refactor covered by the existing
 flat_make_export and nested_component tests
PR: #8904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 6e835ba59b

ℹ️ 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".

Copy link
Copy Markdown
Member

Rechecked 6e835ba with the expanded Next.js end-to-end tests. Nested components and incremental rebuilds ended well 🙂: production builds and both browser tests pass, with the .cmi and compiled consumer unchanged in the rebuild case.

The awaited-namespace and server re-export (.resi) cases still fail Next.js prerendering with an undefined component. Run and artifacts.

cknitt and others added 3 commits October 21, 2026 11:58
Every `make` in a nested module of a file's final signature is also exported
as `A$B$make`, and other modules read it through that name. React Server
Components only pass a client module's top-level exports to the client, so
`<Sidebar.Provider />` no longer reads `.make` from a client reference. Both
sides decide from the signature alone, which replaces `@res.hoistedFunction`
and its `.cmj` table (#8402); see #8901.

Example (rescript):
    // Sidebar.res
    module Provider = {
      @react.component
      let make = (~children) => children
    }
    // Page.res
    let page = <Sidebar.Provider> {React.null} </Sidebar.Provider>

Before (js):
    // Page.res.js
    JsxRuntime.jsx(Sidebar.Provider.make, {children: null})

After (js):
    // Sidebar.res.js
    let Provider$make = Sidebar$Provider;
    export { Provider, Provider$make }
    // Page.res.js
    JsxRuntime.jsx(Sidebar.Provider$make, {children: null})

Source: After from tests/build_tests/react_ppx/src/nested_component_*.res.js
in this diff. Before is master's fallback for a nested read without a
`.cmj` hoisting entry.

Breaking: `@res.hoistedFunction` is gone; it now reports the unused-attribute
warning 101. Nested functions other than `make` are no longer exported flat.
Consumers read `Lib.A$make` instead of `Lib.A.make`, so test spies or mocks
that replace `make` on the module object no longer intercept those reads.

Review: Why can't a `.resi` edit leave consumers stale, as in #8886? The
flat export set is a function of the signature, so it changes exactly when
the `.cmi` does, and both build systems already rebuild dependents then.
Changing only `Sidebar.res` behind an unchanged `.resi` (a module alias, an
external, a functor application, `include`) kept the `.cmi` byte-identical
and every flat export in a scratch build.

Review: Do the aliases defeat tree-shaking? When the module field holds a
top-level binding, the alias is `let A$B$make = make`. Only fields without
one (a wrapped value such as `Some(make)`, a functor result, an awaited
module) fall back to a property read.

Review: What about name clashes? A flat name already used by a top-level
binding is a compile error, as it was for `@res.hoistedFunction`.

Area: lambda codegen frontend typechecker playground tests
Output: changed programs with nested `make` values gain flat exports and
 read them from other modules; 26 committed outputs change, all additive
 apart from the rewritten reads, and 3 go with the removed tests
Formatting: unchanged
Formats: cmi
PPX: unchanged
Editor: unchanged
Errors: none, because the new clash error is an inline raise_errorf, outside
 the catalog's scope
Tests: +tests/build_tests/react_ppx/src/nested_component_producer.res
 +tests/build_tests/react_ppx/src/nested_component_consumer.res
 +tests/build_tests/super_errors/fixtures/flat_make_export_collision.res
 ~tests/tests/src/flat_make_export*.res (from hoisted_function_attr*.res)
 ~tests/tests/src/import2.res
 ~tests/build_tests/super_errors/fixtures/warning_101_bs_unused_attribute.res
 -tests/build_tests/super_errors/fixtures/hoisted_function_*.res
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
A flat `A$B$make` export now only clashes with another export. A binding
hidden by the interface may have the same JS name: the printer renames the
local and exports the alias under the flat name.

Example (rescript):
    // Lib.resi exposes A.B.make and both, but not \"A$B$make"
    module A = {
      module B = {
        let make = () => 1
      }
    }
    let \"A$B$make" = () => Math.random() > 2. ? 0 : 2
    let both = () => A.B.make() + \"A$B$make"()

After (js):
    function A$B$make() { ... }
    let A$B$make$1 = make;
    export { A, both, A$B$make$1 as A$B$make }

Source: tests/tests/src/flat_make_export_hidden.mjs in this diff; the
previous commit rejected this file.

Review: Codex finding on #8904, lam_compile_main.ml:186.

Area: lambda tests
Output: changed tests/tests/src/flat_make_export_*.mjs only, for the new test
Formatting: unchanged
Formats: none
PPX: unchanged
Editor: unchanged
Tests: +tests/tests/src/flat_make_export_hidden.res
 ~tests/tests/src/flat_make_export_test.res
 ~tests/build_tests/super_errors/expected/flat_make_export_collision.res.expected
PR: #8904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
The paths are a field of `Translmod.implementation`, built from the final
signature by `transl_implementation`, and `Lam_compile_main.compile` takes
the whole record. Each step of a path is a `{pos; name}` record instead of
an `(int * string)` pair. Consumer reads and dynamic imports share
`Translmod.flat_make_name`, and the aliases are plain `(ident, value)`
pairs.

Review: fhammerschmidt on #8904, lam_compile_main.mli:33, asked for a
labeled argument or a named type instead of `(int * string) list list`.

Area: lambda codegen playground tests
Output: unchanged, node scripts/test.js -all rebuilds every test project
 with no generated file changing
Formatting: unchanged
Formats: none
PPX: unchanged
Editor: unchanged
Tests: none, because the change is a refactor covered by the existing
 flat_make_export and nested_component tests
PR: #8904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
A flat export of a module that re-exports another file's module, as in
`module Provider = Sidebar.Provider`, now aliases that file's flat export
instead of reading `make` from its module object. Reads below a local bound
to an awaited module, `module D = await Sidebar`, use the flat names too. Both
cases read `make` from a React Server Components client reference before.

Example (rescript):
    // Reexport.res, with a .resi exposing Provider.make
    module Provider = Sidebar.Provider
    // Page.res
    let load = async () => {
      module D = await Sidebar
      <D.Provider> {React.null} </D.Provider>
    }

Before (js):
    // Reexport.res.js
    let Provider$make = Provider.make;
    // Page.res.js
    let D = await import("./Sidebar.res.js");
    return JsxRuntime.jsx(D.Provider.make, {children: null});

After (js):
    // Reexport.res.js
    let Provider$make = Sidebar.Provider$make;
    // Page.res.js
    let D = await import("./Sidebar.res.js");
    return JsxRuntime.jsx(D.Provider$make, {children: null});

Source: bsc of the previous commit and of this one on a scratch copy of
the two files; the same shapes are in
tests/build_tests/react_ppx/src/nested_component_{reexport,consumer}.res.js.

Review: fhammerschmidt's Next.js end-to-end run on #8904 failed
prerendering for exactly these two cases (rescript-lang/end-to-end#16).

Review: `module P = await Sidebar.Group` still reads `P.Provider.make`: `P`
is Sidebar's `Group` export, so its flat names are out of reach without
changing what `P` holds.

Area: lambda codegen tests
Output: changed tests/tests/src/Import.mjs, where an alias now reads the
 awaited module directly (`O.make` instead of `N1.O.make`), and the new
 test outputs
Formatting: unchanged
Formats: none
PPX: unchanged
Editor: unchanged
Tests: +tests/build_tests/react_ppx/src/nested_component_reexport.res
 ~tests/build_tests/react_ppx/src/nested_component_consumer.res
 +tests/tests/src/flat_make_export_reexport.res
 ~tests/tests/src/flat_make_export_test.res
PR: #8904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GUg1NbeoJMi6t6V89yBWh
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@fhammerschmidt Thanks for the end-to-end run! Both failing cases are addressed in 63a47ff (rebased on master first):

  • Server re-export: module Provider = Sidebar.Provider now re-exports Sidebar.Provider$make instead of reading Provider.make from the client reference (let Provider$make = Sidebar.Provider$make).
  • Awaited namespace: reads below module D = await Sidebar use the flat names (D.Provider$make), including module aliases taken from D and a top-level module Top = await Sidebar.

Not covered: module P = await Sidebar.Group still reads P.Provider.make, since P is then the Group export itself.

Could you rerun the Next.js cases against the new pkg.pr.new build?

@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions

Copy link
Copy Markdown

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 63a47ff297

ℹ️ 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".

@tsnobip tsnobip left a comment

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.

the fix works on my project, I like the solution!

@fhammerschmidt

Copy link
Copy Markdown
Member

Also works in our new end-to-end test NExtJS-Repo!

Copy link
Copy Markdown
Member

The extended Next.js checks are now merged in rescript-lang/end-to-end#16. The run against 63a47ff297 passes all four scenarios, including awaited namespaces and server re-exports (eight Playwright tests).

To rerun manually:

  1. Open Next.js RSC compatibility and click Run workflow.
  2. Select branch main, enter compiler: 8904, leave baseline empty, and choose scenario: all.
  3. Click Run workflow. Logs and the nextjs-rsc-results artifact contain the per-scenario results.

Or use the CLI:

gh workflow run validate-nextjs-rsc.yml \
  --repo rescript-lang/end-to-end --ref main \
  -f compiler=8904 -f scenario=all

Run after the compiler's pkg.pr.new publication completes. You can replace 8904 with a published commit SHA to pin the build, or choose nested, incremental, awaited, or reexport to run just one scenario.

This branch has not been deployed

No deployments
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.

Nested JSX components under React Server Components: derive flat exports from the signature

3 participants