I was wondering how do you guys handle HMR with Vite in the React ReScript ecosystem.
Both vite react plugins (vite-plugin-react and vite-plugin-react-swc) are using react-refresh under the hood which makes the hot reloading very inconsistent and often makes the dev server going crazy (it goes through every export of my bs files of my codebase trying some resolution for hmr update to work) where I have to resign to do a manual page reload.
Interfaces are not as bad in reality as they seem conceptually, and the benefits are extensive and have a long tail (not just fast refresh, also compilation speed, maintainability, and more). I’m sure you know what will work for you, but if you haven’t given them a proper try I recommend it.
I think developers have been trained to dislike duplication, correctly; but this is not duplication in the same sense because it’s fully compiler-checked. You can’t get it wrong, and you don’t have to write it by hand.
I’m interested in whether there’s something we can do to make this work without interface files/managing visibility. Why is there a requirement for only one component per file and is there something we can do about that?
It’s slightly complicated by the fact that fast refresh is itself a low level thing that frameworks such as Vite or NextJS implement themselves (using facebook’s react-refresh package).
So using interfaces files to only export React components is okay, it seems to work constantly even with memo/forwardRef wrapping. Exporting values also won’t trigger a full page reload.
But what about functions? Two commons cases: functions that need to be exported for testing & constructor for record types. It seems the general approach is to never export and have them in separate files. However, this approach goes against the ReScript philosophy of using submodules and limiting files to keep the project flat.
Clearly, using a function to trick around the isLikelyComponentType and make the function displayName in PascalCase is a tradeoff I’m willing to accept to avoid separating these functions from their component file.
let hmr = fn => {
let fnDict = fn->Obj.magic->Js.Dict.fromArray
let name = fnDict->Js.Dict.unsafeGet("name")
let updatedName = name->Js.String2.toUpperCase
fnDict->Js.Dict.set("displayName", updatedName)
fnDict->Obj.magic
}
let makeItem = (~label, ~key="", ~value=None, ~disabled=false, ()) =>
{ label, key, value, disabled }
@react.component
let make = () => ...
let make = React.memo(make)
let makeItem = Helpers.hmr(makeItem)
I wonder if it’d be feasible to maintain a fork of react-refresh that’s tailored to ReScript. Would feel better than playing constant catch up with these things, although we would of course be playing another type of catch up.
Maybe the JSX transform could add the displayName property directly. This has probably been considered before, but would be interesting to investigate.
I know it’s not the main topic of your answer, but I don’t like changing visibility of functions only for testing reasons, that’s why I created a small PPX that allows to write tests inline, that’s one more positive point for creating tight interface files.
Here are my observations:
Vite (rollup?) seems to be really smart with finding module dependencies, if a javascript module A uses module B’s function B.doThing then a dependency edge A → B is made. If B is changed (and invalidated in vite terms), then A will also be invalidated. Anything that depends on A will not be necessarily be invalidated this the change propagation will end.
Say you change the rescript file for B, and then let rescript complie your project by compiling B and then compiling A, because A depends on B’s doThing, then both B and A seems to be written to disk regardless of if content changes (I watched inotify). Typically this means that any file change will cause the root file to be recompiled through a path from the changed file to the root and finally trigger a HMR reload.
We get around this by skipping changes via a content-hash.