Skip to content

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

Draft
cknitt wants to merge 1 commit into
masterfrom
jsx-flat-exports
Draft

cknitt wants to merge 1 commit 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.
  • 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/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

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>
@github-actions

Copy link
Copy Markdown

@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-18T17:41:12.202326Z 93962f9 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 88.46154% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.07%. Comparing base (5e3cc82) to head (93962f9).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
compiler/core/lam_compile_main.ml 81.39% 8 Missing ⚠️
compiler/ml/translmod.ml 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8904      +/-   ##
==========================================
+ Coverage   81.04%   81.07%   +0.02%     
==========================================
  Files         455      455              
  Lines       63508    63460      -48     
==========================================
- Hits        51469    51449      -20     
+ Misses      12039    12011      -28     
Files with missing lines Coverage Δ
compiler/core/js_cmj_format.ml 92.85% <ø> (-0.48%) ⬇️
compiler/core/js_implementation.ml 84.16% <100.00%> (+0.13%) ⬆️
compiler/core/lam_compile.ml 90.16% <100.00%> (+0.13%) ⬆️
compiler/core/lam_compile_env.ml 85.71% <ø> (-0.83%) ⬇️
compiler/core/lam_compile_primitive.ml 81.19% <100.00%> (+0.05%) ⬆️
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% <ø> (ø)
compiler/ml/translattribute.ml 73.91% <ø> (-3.02%) ⬇️
... and 4 more

... and 5 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 on lines +182 to +186
if Set_string.mem js_name occupied_names then
Location.raise_errorf ~loc
"Cannot export `%s` as `%s` because that name is already used \
by a top-level binding."
(String.concat "." names) name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict flat-name collision checks to public bindings

When a file has a .resi, this rejects a private implementation binding whose JS name happens to equal a generated flat export. For example, an implementation can expose A.B.make while retaining a used but signature-hidden let "A$B$make" = ...; because occupied_names contains every surviving Lambda group rather than only export_ids, compilation now reports a collision even though the private local can be renamed and only the generated alias needs the public A$B$make export name. Limit this collision check to bindings that are actually exported so previously valid implementations remain compilable.

Useful? React with 👍 / 👎.

@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/darwin-x64

npm i https://pkg.pr.new/@rescript/darwin-x64@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: 93962f9

@github-actions

Copy link
Copy Markdown

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

1 participant