rescript-compiler icon indicating copy to clipboard operation
rescript-compiler copied to clipboard

Cost of inline modules

Open dodomorandi opened this issue 10 months ago • 1 comments

I was playing around and I realized that, with the current approach to compile inline modules, we produce JS code that cannot be tree-shaked (at least using esbuild 0.18.13).

// Split.res

module Nested = Split__Nested
// Split__Nested.res

let adder = x => {
  x + 2
}

let multiplier = x => {
  x * 2
}
// Inlined.res

module Nested = {
  let adder = x => {
    x + 2
  }

  let multiplier = x => {
    x * 2
  }
}
// MainSplit.res

Console.log(Split.Nested.adder(2)->Int.toString)
// MainInlined.res

Console.log(Inlined.Nested.adder(2)->Int.toString)
// MainSplit.resi
// MainInlined.resi

If you compile this (I am currently using rescript 11.0.0-beta.4), then you can use esbuild to minify and bundle the two main files separatedly (--keep-names is just to make things a bit more readable):

rescript build
esbuild --minify --bundle --keep-names src/MainSplit.bs.js
esbuild --minify --bundle --keep-names src/MainInlined.bs.js

This is the formatted output for MainSplit:

(() => {
  var i = Object.defineProperty;
  var e = (t, o) => i(t, "name", { value: o, configurable: !0 });
  function r(t) {
    return (t + 2) | 0;
  }
  e(r, "adder");
  console.log(r(2).toString());
})();

This is the output for MainInlined instead:

(() => {
  var d = Object.defineProperty;
  var r = (e, n) => d(e, "name", { value: n, configurable: !0 });
  function i(e) {
    return (e + 2) | 0;
  }
  r(i, "adder");
  function o(e) {
    return e << 1;
  }
  r(o, "multiplier");
  var t = { adder: i, multiplier: o };
  console.log(t.adder(2).toString());
})();

As you can see, we keep the multiplier function around in the inlined version, and the reason is pretty simple: because we represent an inline module as an object, it is impossible for the tree-shaker to only remove multiplier from the object t.


I hope to be wrong, but I do not think there is a nice and easy solution, just because it is not possible to express the concept of an inline module in JS.

We could simply consider to warn the users that inline modules can lead to bundle size increase, but given that in Rescript modules are first citizen elements (and they are pretty pervasives) maybe we should consider exploring better approaches.

An obvious one could be to automatically split inline modules into separated JS files, but the following situation is totally not trivial to handle:

// Test.res
let a = x => {
  x + 10
}

module Inner = {
  let b = x => {
    a(x) * 2
  }
}
// Test.resi

// Note that `a` is not exported
module Inner: {
  let b: int => int
}

In fact, a should be exported to Inner but not to other modules according to the interface file.


As you can see, I really do not have a good solution to this problem. Probably the situation is made a little worse by the way module files are flat in Rescript. But let me know what you think.

dodomorandi avatar Aug 14 '23 12:08 dodomorandi