How to Resolve Export Format Conflicts Between ReScript and React Router v7 for Hot Module Replacement?

I export JavaScript files generated by ReScript in the format:

export {
  loader$1 as loader,
  make,
  $$default as default,
}

However, Hot Module Replacement in React Router v7 requires:

https://reactrouter.com/explanation/hot-module-replacement

export const headers = { "Cache-Control": "max-age=3600" }; // ✅
export const loader = async () => {}; // ✅
export const action = async () => {}; // ✅

What solution can address this issue?

@joshderochervlk got this working with react-router v7.
Maybe you can find some hints in this PR:

Thanks for your reply, but export {loader} won’t work in React Router, export const loader is required instead.

Hm, I believe this is not actually necessary.

At least it seems to work for me in the rescript-lang.org repo. I just changed something in the loader:
rescript-lang.org/app/routes/BlogRoute.res at master · rescript-lang/rescript-lang.org · GitHub
and it updated immediately in the browser.

So it seems you just need a .resi file for it to work?

Bear in mind we use .jsx everywhere in the docs repo, not sure if that makes a difference.

I used SSR, this appears to be related to SSR.
loader is server function.
Maybe caused by tree shaking?

It’s probably the alias when exporting.

You need to make sure somehow that loader is not renamed in the export object I think.

export {
  loader$1 as loader,
  make,
  $$default as default,
} 

This is the final output generated by Rescript.
When I changed the js file to export const loader = XXX, export default XXX
It started up normally when running in dev mode using the React Router CLI.

What does the code look like?

module Inner = {
  let loader = async () => Console.log("inner")
}

let loader = async () => Console.log("toplevel")

e.g. this compiles to

async function loader() {
  console.log("inner");
}

let Inner = {
  loader: loader
};

async function loader$1() {
  console.log("toplevel");
}

export {
  Inner,
  loader$1 as loader,
}

maybe you can restructure your code so that the compiler emits it without aliases. In the example above for instance by moving the Inner loader to a separate file.

The most basic client-side React Router approach that can be executed normally.

let default = () => {
  <strong> {"hi"->React.string} </strong> 
}
import * as JsxRuntime from "react/jsx-runtime";

function $$default() {
  return JsxRuntime.jsx("strong", {
    children: "hi"
  });
}

export {
  $$default as default,
}

However, when using server-side SSR loaders, it fails to execute properly.

type t = {
  text: string
}
type componentProps = {
  loaderData: t
}
let loader = () => Promise.resolve({
  text: "about"
})

let default = (props: componentProps) => {
  let {loaderData} = props
  <strong> {loaderData.text->React.string} </strong> 
}
import * as JsxRuntime from "react/jsx-runtime";

function loader() {
  return Promise.resolve({
    text: "about"
  });
}

function $$default(props) {
  return JsxRuntime.jsx("strong", {
    children: props.loaderData.text
  });
}

export {
  loader,
  $$default as default,
}

But when I add that export default inside it, it can execute normally.

type t = {
  text: string
}
type componentProps = {
  loaderData: t
}
let loader = () => Promise.resolve({
  text: "about"
})

let make = (props: componentProps) => {
  let {loaderData} = props
  <strong> {loaderData.text->React.string} </strong> 
}

%%raw(`export default make`) 
import * as JsxRuntime from "react/jsx-runtime";

function loader() {
  return Promise.resolve({
    text: "about"
  });
}

function make(props) {
  return JsxRuntime.jsx("strong", {
    children: props.loaderData.text
  });
}

export default make
;

export {
  loader,
  make,
}

So now I want to ask if there are better methods to replace %%raw('export default make') in order to achieve export default.

I tried writing a vite transformation plugin to enable code written in ReScript to run properly in SSR mode with React Router v7 in Vite. However, I still haven’t found the reason why it doesn’t support the error caused by export { someVariable as default } .
But it seems to be working properly now.

// vite-plugin-fix-dollar-default-export.js
export default function fixDollarDefaultExport() {
  return {
    name: 'fix-dollar-default-export',
    
    transform(code, id) {
      if (!/\.(js|jsx|ts|tsx|mjs|cjs)$/.test(id)) return null;
      
      let transformedCode = code;
      let hasChanged = false;
      
      const exportBlockPattern = /export\s*\{([^}]+)\}/g;
      const exportBlocks = [...transformedCode.matchAll(exportBlockPattern)];
      
      for (const block of exportBlocks) {
        const fullMatch = block[0];
        const exportContent = block[1].trim();
        
        if (exportContent.includes('as default')) {
          const exportItems = exportContent.split(',')
            .map(item => item.trim())
            .filter(item => item.length > 0);
          
          const defaultExports = [];
          const normalExports = [];
          
          for (const item of exportItems) {
            const asDefaultMatch = item.match(/^(.+?)\s+as\s+default$/);
            
            if (asDefaultMatch) {
              const varName = asDefaultMatch[1].trim();
              
              if (/^[\w$]+$/.test(varName)) {
                defaultExports.push(varName);
              } else {
                normalExports.push(item);
              }
            } else {
              normalExports.push(item);
            }
          }
          
          if (defaultExports.length > 0) {
            let newExports = '';
            
            if (normalExports.length > 0) {
              newExports += `export { ${normalExports.join(', ')} };\n`;
            }
            
            defaultExports.forEach(varName => {
              newExports += `export default ${varName};\n`;
            });
            console.log('origin: ', transformedCode)
            transformedCode = transformedCode.replace(fullMatch, () => newExports.trim());
            console.log(fullMatch + ' ---> ' +  newExports.trim())
            console.log("result: ", transformedCode)
            hasChanged = true;
          }
        }
      }
      
      if (hasChanged) {
        return {
          code: transformedCode,
          map: null
        };
      }
      
      return null;
    }
  };
}
// vite.config.js
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import rewrite from './vite-plugin-fix-export-default'

export default defineConfig({
  plugins: [tailwindcss(), rewrite(), reactRouter(), ]  
});

input

origin:  // Generated by ReScript, PLEASE EDIT WITH CARE

import * as JsxRuntime from "react/jsx-runtime";

function loader() {
  return Promise.resolve({
    text: "about 1"
  });
}

function About$default(props) {
  return JsxRuntime.jsx("strong", {
    children: props.loaderData.text
  });
}

let $$default = About$default;

export {
  loader,
  $$default as default,
}
/* react/jsx-runtime Not a pure module */

output:

result:  // Generated by ReScript, PLEASE EDIT WITH CARE

import * as JsxRuntime from "react/jsx-runtime";

function loader() {
  return Promise.resolve({
    text: "about 1"
  });
}

function About$default(props) {
  return JsxRuntime.jsx("strong", {
    children: props.loaderData.text
  });
}

let $$default = About$default;

export { loader };
export default $$default;
/* react/jsx-runtime Not a pure module */

Translation target:


export {
  loader,
  $$default as default,
} ---> export { loader };
export default $$default;
1 Like

It’s tricky to work around, but the rescript doc site is using react router v7 with loaders and HMR works for the most part. There are a lot of files with multiple components that can be broken up, but changes to loaders works.

It would be nice to have exports live next to the value instead of all exported at the end as an object.

I can’t find where I read it, but there afaik there is a difference between these two exports and how they work in ESM and tree shaking.

// option one
export const foo = 42
// option two
const foo = 42

export { foo }
1 Like