Skip to content

Add @res.hoistedFunction support for flat JS export - #8402

Open
cknitt wants to merge 12 commits into
masterfrom
hoisted-functions
Open

Add @res.hoistedFunction support for flat JS export#8402
cknitt wants to merge 12 commits into
masterfrom
hoisted-functions

Conversation

@cknitt

@cknitt cknitt commented May 1, 2026

Copy link
Copy Markdown
Member

Summary

Adds @res.hoistedFunction, a compiler-supported attribute for exporting nested module functions through flat JS exports.

A function defined inside an exported module can now be marked as hoisted. The compiler keeps the normal nested module shape, but also emits a root-level alias/export for the function and records that alias in .cmj metadata so downstream modules can import it directly.

Motivation

Nested module functions are normally emitted and consumed through property access:

// Producer.res
module A = {
  module B = {
    let make = () => "ok"
  }
}
// consumer output
Producer.A.B.make()

This is the first step for my take on #8293. That work needs generated JSX code for React Server Components to expose nested component functions through stable flat JS exports:

Producer.A$B$make()

Because producers and consumers are compiled separately, the consumer cannot infer from the source path alone whether Producer.A.B.make also has a flat export. The producer therefore records hoisted paths in .cmj metadata, and consumers use that metadata to emit the flat import when it is available.

Design

Source Marker

The feature introduces:

@res.hoistedFunction
let make = () => ...

The attribute applies only to function bindings. Attributes on unsupported syntax locations, including external declarations, are reported by the compiler's standard unused-attribute check. Attributes on value bindings that cannot be hoisted for semantic reasons—such as non-function values or unsupported binding patterns—produce a misplaced-attribute warning.

The attribute deliberately applies only to nested module function bindings with a fixed exported path, matching the intended use case. Local functions, functions declared directly at the file root, local modules, and functor bodies cannot form such an export and produce a misplaced-attribute warning. The complete path must also remain visible after signature coercion: if an explicit module signature or the file's .resi hides the function or an enclosing module, the compiler reports the same warning and does not generate an alias.

Producer Output

When an exported module contains a hoisted nested function, the compiler keeps the original module structure and adds a root-level alias.

For a source path like:

A.B.make

the compiler emits a flat export:

A$B$make

The nested function remains available at its normal path, while the flat alias is exported as a separate JS value.

Path Identity and Exotic Identifiers

The flat JavaScript name cannot safely identify the original source path by itself.

Representing a nested path by joining its segments with $ would be ambiguous because ReScript allows escaped identifiers containing $. For example, these are distinct source paths:

A.B.make
A.\"B$make"

However, both have the same natural flat JavaScript spelling:

A$B$make

Encoding the path segments in the generated export name would remove the ambiguity, but it would also produce surprising JavaScript names and prevent exotic identifiers from retaining their intended spelling.

Instead, the implementation separates source-path identity from the generated JavaScript name:

  • .cmj metadata stores the exact source path as a list of segments.
  • The generated export retains its natural flat JavaScript name.

Consumers therefore look up hoisted exports using the structural source path, while the generated JavaScript remains readable and predictable.

If two hoisted paths produce the same JavaScript export name, or if that name conflicts with an existing top-level binding, compilation fails with a clear error.

.cmj Metadata

The .cmj format stores each hoisted export as:

type hoisted_export = {
  path: string list;
  export_name: string;
}

For example, A.B.make is represented as:

{
  path: ["A", "B", "make"];
  export_name: "A$B$make";
}

The path is used for exact source-level lookup. The export name is the compiler identifier used for the flat JavaScript export.

This metadata remains separate from the regular values table, which continues to hold arity and cross-module optimization information. Because adding this metadata changes the marshaled .cmj schema, the compiler compatibility marker is bumped to Caml1999I024, ensuring stale compiled artifacts are rebuilt after an upgrade.

Consumer Lookup

When compiling a cross-module nested read such as:

Producer.A.B.make

normal compilation looks up the first field, A, in the producer’s .cmj and emits the remaining property accesses:

Producer.A.B.make

For a nested read, the compiler now also reconstructs the exact source path and checks the producer’s hoisted export metadata.

If the path is present, it uses the recorded flat export name:

Producer.A$B$make

Otherwise, it falls back to the normal property chain.

Because lookup uses the structural path rather than a $-joined key, exotic identifiers cannot accidentally resolve to another binding’s hoisted export.

Performance

Compilation units without registered hoisted functions skip alias collection immediately.

For annotated functions, the compiler indexes the existing Lambda groups and resolves each recorded source path against the final exported module structure. It also verifies that the path still refers to the exact annotated binding, so a function hidden by a signature or replaced by a shadowing binding is not aliased. This is compile-time only and proportional to the module output and the normally short paths involved.

For each annotated function, the generated JavaScript contains one additional alias assignment and export. Calls use the flat export directly; there is no runtime path lookup or encoding.

Consumer lookup is compile-time only and scans the dependency’s normally very small list of hoisted exports.

Tests

Adds coverage for:

  • several levels of nested modules;
  • mutually recursive modules;
  • function bindings with explicit type annotations;
  • hoisted and non-hoisted functions in the same modules;
  • cross-module access through flat exports;
  • escaped keyword, operator, and $ identifiers;
  • structurally distinct paths with the same flattened spelling;
  • collisions between hoisted exports;
  • collisions with existing top-level bindings;
  • invalid attribute payloads and placements where no flat export can be generated;
  • functions hidden by explicit module signatures or .resi files;
  • executed runtime behavior of the generated JavaScript.

@cknitt

cknitt commented May 1, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 6dfddc0098

ℹ️ 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/js_cmj_format.ml Outdated
@pkg-pr-new

pkg-pr-new Bot commented May 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript-lang/rescript@8402

@rescript/belt

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/belt@8402

@rescript/darwin-arm64

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

@rescript/darwin-x64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/runtime@8402

@rescript/win32-x64

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

commit: 4cc7110

@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: da4b5b3307

ℹ️ 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 Outdated
Comment thread compiler/ml/translcore.ml Outdated
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.76404% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.98%. Comparing base (cf8dd8c) to head (4cc7110).

Files with missing lines Patch % Lines
compiler/core/lam_compile_main.ml 79.51% 17 Missing ⚠️
compiler/core/lam_compile.ml 96.42% 1 Missing ⚠️
compiler/core/lam_compile_env.ml 90.00% 1 Missing ⚠️
compiler/ml/translcore.ml 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8402      +/-   ##
==========================================
+ Coverage   75.95%   75.98%   +0.02%     
==========================================
  Files         474      474              
  Lines       62905    63054     +149     
==========================================
+ Hits        47779    47909     +130     
- Misses      15126    15145      +19     
Files with missing lines Coverage Δ
compiler/core/js_cmj_format.ml 92.45% <100.00%> (+0.61%) ⬆️
compiler/core/js_implementation.ml 84.40% <100.00%> (ø)
compiler/core/lam_stats_export.ml 90.69% <100.00%> (ø)
compiler/frontend/bs_ast_invariant.ml 90.90% <100.00%> (+0.13%) ⬆️
compiler/frontend/bs_builtin_ppx.ml 90.97% <100.00%> (+0.35%) ⬆️
compiler/ml/lambda.ml 70.37% <ø> (ø)
compiler/ml/translattribute.ml 74.60% <100.00%> (+3.17%) ⬆️
compiler/ml/translmod.ml 96.31% <100.00%> (+0.27%) ⬆️
compiler/core/lam_compile.ml 85.57% <96.42%> (+0.14%) ⬆️
compiler/core/lam_compile_env.ml 90.90% <90.00%> (-1.40%) ⬇️
... and 2 more

... and 1 file 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.

@github-actions

Copy link
Copy Markdown

@cknitt
cknitt force-pushed the hoisted-functions branch from da4b5b3 to 8595ead Compare August 22, 2026 14:11
@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 8595ead79d

ℹ️ 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/ml/translcore.ml Outdated
Comment thread compiler/ml/translmod.ml
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the hoisted-functions branch from 8595ead to 3c50e90 Compare August 22, 2026 14:35
@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 3c50e90bf7

ℹ️ 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/ml/translmod.ml Outdated
Comment thread compiler/ml/translcore.ml Outdated
cknitt added 2 commits August 22, 2026 17:14
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the hoisted-functions branch from 46cebb7 to ea70870 Compare August 22, 2026 18:37
@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: ea708706b9

ℹ️ 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/ml/translmod.ml Outdated
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: cb7e60c88e

ℹ️ 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 Outdated
Comment thread compiler/core/lam_compile_main.ml Outdated
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 24763f159e

ℹ️ 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
Comment thread compiler/ml/translmod.ml Outdated
Comment thread tests/tests/src/hoisted_function_attr.res
cknitt added 2 commits August 23, 2026 07:48
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: f52fd1a63e

ℹ️ 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/js_cmj_format.ml
Comment thread compiler/ml/translmod.ml Outdated
cknitt added 2 commits August 23, 2026 08:08
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: b8b8c209d7

ℹ️ 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/ml/translcore.ml
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 3f6fae317d

ℹ️ 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/ml/translmod.ml Outdated
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: d1900ac709

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

Signed-off-by: Christoph Knittel <ck@cca.io>

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder whether we should frame this problem more broadly as an opt-in module export ABI. A module could designate one function as its JavaScript representation, so that a ReScript access such as M.make is emitted as a direct reference to an exported function named M. This would give component-like modules a natural, stable JavaScript identity without exposing the implementation detail that their principal function happens to be called make.

For example:

module Button = {
  @module.owner
  let make = (~label) => React.string(label)
}

could be represented as:

function Button(props) {
  // ...
}

export { Button };

A consumer would still write:

Button.make(~label="Save")

but the generated JavaScript would call:

Button({label: "Save"})

The same idea would apply across files and nested modules. Given:

// Sidebar.res
module Provider = {
  @module.owner
  let make = (~children) => children
}

this source expression:

Sidebar.Provider.make(~children)

could compile to a direct reference to the Provider export:

import * as Sidebar from "./Sidebar.res.mjs";

Sidebar.Provider({children});

For deeper nesting, an implementation could flatten the module path while omitting the designated member name:

Layout.Sidebar.Provider.make
Layout.Sidebar$Provider(props)

Compared with adding an export such as Provider$make while retaining Provider.make, this model defines one preferred JavaScript identity rather than two parallel identities. It would be an explicit, opt-in change to the module's generated JavaScript API, so the compatibility implications would also be clear.

There are, of course, many details to work out before this could become a complete design. A module may contain values other than its designated function:

module Button = {
  @module.owner
  let make = props => ...

  let defaultSize = 20
  let renderIcon = icon => ...
}

One possible representation would be:

export function Button(props) {
  // ...
}

export const Button$defaultSize = 20;
export function Button$renderIcon(icon) {
  // ...
}

but that is only one option. We would also need to decide how whole-module uses, module aliases, include, functors, first-class and recursive modules, signatures, naming collisions, separate compilation, and existing JavaScript consumers should behave. I do not think we need to settle all of those questions in this PR, but before committing to the more general hoisted-function machinery, it seems worth deciding whether this opt-in module ABI is the direction we ultimately want—especially for React components and other modules organized around one principal function.

@cknitt

cknitt commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

This is an interesting direction, but I think it describes a substantially broader module ABI feature rather than a simplification of this PR.

A few thoughts:

  • Module signatures are checked statically, so a signature alone does not require every member to remain a JS object property. However, removing the field would require us to define what happens when the module is used as a whole—for example through aliases, include, functors, first-class or recursive modules, signature coercions, and existing JS consumers.

  • Deeper nesting does not disappear. Turning Layout.Sidebar.Provider.make into Layout.Sidebar$Provider still requires structural path metadata, collision handling, .cmj propagation, binding-identity tracking, and cross-module rewriting. It mainly changes the generated name by omitting $make.

  • The current representation does not create two function objects. The normal module field and the additional flat export reference the same function. There are two access paths, but only one runtime function identity.

  • The motivating JSX work in [PoC]: Rewrite nested JSX component paths to direct hoisted exports #8293 intentionally leaves ordinary ReScript value access unchanged. An owner-based module ABI expands that focused fix into a considerably larger design discussion and could delay it.

For this PR, I would therefore prefer to keep the additive behavior: preserve the normal module representation and provide an additional direct export. An opt-in owner ABI could be explored separately and could likely reuse much of the path and export machinery introduced here.

in
let args_code : J.block = List.concat args_block in
let exp =
(* TODO: all can be done in [compile_primitive] *)

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.

forgotten or is that one for me to continue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This part of the code was just moved, the TODO was there before.

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