Fable + react-hot-loader (without Elmish) generates TypeError

Created on 13 Feb 2019  路  8Comments  路  Source: fable-compiler/Fable

Description

When using React (without Elmish) and react-hot-loader (https://github.com/gaearon/react-hot-loader), a TypeError is thrown:

TypeError: Property value expected type of string but got null
...

Repro code

Download and extract the following Test project: Test.zip

Run yarn, then yarn start in the root, and observe the error.

If you open webpack.config.js, you'll find a variable at the top enableHMR. If you set this to false, you can see that the app works just fine without HMR.

The specific webpack.config.js configuration option that causes this is setting the fable loader's babel options to have plugins: ["react-hot-loader/babel"].

I believe this is the correct thing to do, based off of react-hot-loader's recommended way of working with TypeScript: https://github.com/gaearon/react-hot-loader#typescript.

Additionally, for reference, here are all of the configuration options and code that should enable HMR in the project:

webpack.config.js:

babel options plugins...

if (enableHMR)
    babelOptions.plugins = ["react-hot-loader/babel"]

and passing babelOptions to fable-loader and babel-loader.

devServer setup...

    devServer: isDevelopment ? {
        contentBase: path.resolve("./app"),
        headers: { "Access-Control-Allow-Origin": "*" },
        port: 8080,
        hot: enableHMR,
        inline: enableHMR
    } : undefined,

additional entries...

    entry: [path.resolve("./src/Test.fsproj")].concat(
        enableHMR ? [
            "webpack-dev-server/client?http://localhost:8080",
            "webpack/hot/only-dev-server"
        ] : []

webpack plugins...

    plugins: [
        new CopyWebpackPlugin([{ from: "**/*.html", context: path.resolve("./src") },]),
        new WriteFilePlugin()
    ].concat(enableHMR ? [
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NamedModulesPlugin()
    ] : [])

and finally the code...

#if DEBUG
[<Import("hot", "react-hot-loader/root")>]
let HotReload: obj -> obj = jsNative
#else
let HotReload (f: obj) = f
#endif
let CreateTest() =
    let comp() = React.ofType<Test, _, _> (obj()) []
    React.createElement(HotReload comp, obj(), [])

ReactDom.render(CreateTest(), Browser.document.getElementById("app_container"))

Everything above was taken from the main react-hot-loader documentation and seems to be correct, but I think there may be some incompatibility with what Fable is doing with Babel...

Ultimately, I much prefer to use pure React and local state over Elmish because of the sheer number of forms I have which causes Elmish's global state to get very noisy and labor intensive. Hopefully this is some simple bug that can be fixed so I can have my cake and eat it too :)

Related information

  • Fable version:
    yarn info v1.12.3
    2.1.12

  • Operating system
    Windows 10 x64

Most helpful comment

Figured it all out and tested, and everything is working now. Below is a break-down of the code necessary to get it to work (excerpted from my comment over on the RHL issue):

In your root component...

let private hot: obj -> obj = import "hot" "react-hot-loader/root"

let Component = hot <| fun () -> React.ofType<Main, _, _> { input = obj(); output = obj() } []

Here, my root component is a class-component called Main. The React.ofType generates the React.createComponent call. I also import hot here. This is a must, and you CANNOT wrap it as a utility function in some other file (which is pretty standard Fable/F# practice), since I'm guessing it implicitly grabs a reference to the enclosing module. The reason I use obj as the type is because Fable does not have a unifying type to refer to a "react component". A react component is either a function type (returning ReactElement) or a ComponentClass, so it's easier to just use obj and get on with it. Fable will generate the export needed for the Component variable, since it is a top-level binding.

In your top-level rendering file (application entry point)...

ReactDom.render(React.createElement(Main.Component, obj(), []), Browser.document.getElementById "app_container")

Here, we use the standard render function and use React.createElement on our hot exported root component.
It is vital as well that you do not combine these two files. RHL throws a warning/error in the console if you try to use the result of hot directly instead of simply exporting it like above.

It's a bit touchy overall, because you have to be careful and think about the generated JS, but it works just fine. Now I can tweak my layouts/forms no matter how nested they are and even maintain their state while doing so.

I'll put in that PR for fable-loader here soon as well.

Woooooooo 馃帀

All 8 comments

Hmm, unfortunately I don't have much time to test this right now and I don't know the HMR mechanism very well either as it was @MangelMaxime who did all the magic for Elmish. Not sure what the "react-hot-loader/babel" plugin is doing but if it expects JSX that can be a problem because Fable.React directly outputs the JS syntax.

Curried signatures a -> b -> c cause problems sometimes when importing code so I would try with the following (although I doubt this is the issue as it's only 1-arity here):

#if DEBUG
[<Import("hot", "react-hot-loader/root")>]
#endif
let HotReload (f: obj) = f

I would try to break when the exception is thrown and see the stacktrace to find the point where Fable is sending null instead of a string.

Thanks for the quick response. I'll try to dig further into the error like you suggest when time permits. For now, not having hot-reloading isn't much a setback, definitely just more of a nice-to-have. I'll post back here with any additional details I discover along the way.

Alright, so after a late night of digging around, it seem that the react-hot-loader/babel plugin expects a valid value for babelOptions.filename. And so, by adding a line to the fable-loader code (by just manually patching the node_modules file):

function transformBabelAst(babelAst, babelOptions, sourceMapOptions, callback) {
    var fsCode = null;
    if (sourceMapOptions != null) {
        fsCode = sourceMapOptions.buffer.toString();
        babelOptions.sourceMaps = true;

        // this was added
        babelOptions.filename = sourceMapOptions.path.replace(/\\/g, '/');

        babelOptions.sourceFileName = path.relative(process.cwd(), sourceMapOptions.path.replace(/\\/g, '/'));
    }
    babel.transformFromAst(babelAst, fsCode, babelOptions, callback);
}

The webpack error goes away and HMR is working.

However, even though it's 100% working (I can change the code successfully and even the React components' state is preserved as expected AND I can change things like onClick handlers, which is awesome), I still get this strange error coming from the final code after each hot-update:
err

There unfortunately isn't any more information other than the message that gets passed. I tracked down the code that is spitting the error (located in the final bundle) and it looks like this:

var chargeFailbackTimer = function chargeFailbackTimer(id) {
  return setTimeout(function () {
    var error = 'hot update failed for module "' + id + '". Last file processed: "' + getLastModuleOpened() + '".';
    logger.error(error);
    logException({
      toString: function toString() {
        return error;
      }
    });
    // 100 ms more "code" tolerant that 0, and would catch error in any case
  }, 100);
};

which is called later like this...

...
  makeHotExport(sourceModule);

  clearExceptions();
  var failbackTimer = chargeFailbackTimer(sourceModule.id);
  var firstHotRegistered = false;

  // TODO: Ensure that all exports from this file are react components.

  return function (WrappedComponent, props) {
    clearFailbackTimer(failbackTimer);
    // register proxy for wrapped component
    // only one hot per file would use this registration
    if (!firstHotRegistered) {
      firstHotRegistered = true;
      reactHotLoader.register(WrappedComponent, getComponentDisplayName(WrappedComponent), 'RHL' + moduleId);
    }
...

It seems to be some sort of timeout error since it is set up to happen after 100ms unless canceled by the clearFailbackTimer call... but at this point I am pretty lost as to what is causing it.

I'm hoping is it yet another missing field in babelOptions, so we can simply add it in fable-loader, but I'll have to poke around with it some more. If I can't seem to find anything that affects it, I'll probably post an issue to react-hot-loader and see if they have any ideas.

Almost there! 馃帀

Great work @SirUppyPancakes! Shouldn't be a problem to add .filename if you send a PR. We didn't need it so far and that's we haven't touched it.

About the other error, if there's a timeout, 100ms may be a bit too strict for the Fable compiler. As you already pinpointed the function, san you try making the timeout more generous and see if it changes something?

I'll send a PR once I'm confident that there isn't anything else that RHL needs (should be resolved soon I think; we'll see what they say over at the RHL issue).
I'll give that timeout suggestion a try too and see if anything changes :)

Just tried setting it all the way up to 15000ms and it still did it after 15 seconds, so I am thinking that it must be some other thing. It'll be interesting to see what the developers over at RHL have to say about this function...

Ah I think I wasn't exporting the component correctly so that hot was never getting called. I believe it's working properly now, though I have to do some more testing tonight to be sure. I'll put that PR in sometime tonight too!

Figured it all out and tested, and everything is working now. Below is a break-down of the code necessary to get it to work (excerpted from my comment over on the RHL issue):

In your root component...

let private hot: obj -> obj = import "hot" "react-hot-loader/root"

let Component = hot <| fun () -> React.ofType<Main, _, _> { input = obj(); output = obj() } []

Here, my root component is a class-component called Main. The React.ofType generates the React.createComponent call. I also import hot here. This is a must, and you CANNOT wrap it as a utility function in some other file (which is pretty standard Fable/F# practice), since I'm guessing it implicitly grabs a reference to the enclosing module. The reason I use obj as the type is because Fable does not have a unifying type to refer to a "react component". A react component is either a function type (returning ReactElement) or a ComponentClass, so it's easier to just use obj and get on with it. Fable will generate the export needed for the Component variable, since it is a top-level binding.

In your top-level rendering file (application entry point)...

ReactDom.render(React.createElement(Main.Component, obj(), []), Browser.document.getElementById "app_container")

Here, we use the standard render function and use React.createElement on our hot exported root component.
It is vital as well that you do not combine these two files. RHL throws a warning/error in the console if you try to use the result of hot directly instead of simply exporting it like above.

It's a bit touchy overall, because you have to be careful and think about the generated JS, but it works just fine. Now I can tweak my layouts/forms no matter how nested they are and even maintain their state while doing so.

I'll put in that PR for fable-loader here soon as well.

Woooooooo 馃帀

Was this page helpful?
0 / 5 - 0 ratings

Related issues

MangelMaxime picture MangelMaxime  路  3Comments

et1975 picture et1975  路  3Comments

MangelMaxime picture MangelMaxime  路  3Comments

SirUppyPancakes picture SirUppyPancakes  路  3Comments

theprash picture theprash  路  3Comments