Skip to content

Changes to support hot-reloading / React Refresh #4643

Open
@japgolly

Description

@japgolly

Background

A common complaint that I hear often against using Scala.js, is that the dev feedback cycle is really fast with JS but really slow with Scala. I'm referring to the amount of time it takes between modifying a source file and seeing the change reflected in a browser. The JS community have really endeavoured to get this time down to as fast as possible, both by the creation of dev servers that push updates to the browser immediately, and by hot-reloading tech that swaps out parts of a live app without requiring a page reload or loss of state (where possible). The result is that JS devs can enjoy effectively-instant updates in the browser when making local changes. Giving up this fast feedback cycle often seems to be a hard sell for people considering Scala.js.

Goal

I'm currently working on (hopefully) adding support for React Refresh (i.e. hot reloading) to scalajs-react. By combining Scala.js's awesome ability to spit out fine-grained ES modules, and a fast bundler that supports hot-reloading (like Vite), I hope to provide a fast dev feedback cycle using Scala, that can stand up to the JS experience. It's likely to be in the order of 500ms on the Scala side compared to 5ms in JS, which from a human's pov should be equivalent enough. For large projects it might be 2 sec, but that's way more acceptable than the current Scala status quo of around 20 sec.

Problems

After much experimentation and debugging, I was able to get Vite hot-reloading Scala.js, and it's fast! The approach seems like to should work seamlessly with bundlers like Webpack, etc too.🥳

However, hacks were required; and by hacks, I mean part of what I had to do was manually hack and chop up the Scala.js output. These were the problems I encountered. For objects that contain scalajs-react components...

  • the object's class needs to be in its own module, and be the sole and default export. -- If Vite sees a non-default export, it won't hot-reload that module.

  • the object's class module needs to eagerly initialise the object. -- Bundles will inject hot-reloading code into the module and expect it to be used by the time the module has finished loading. Calling the hot-reload code when Scala decides to initialise the module doesn't work because the module has already loaded, and the hot-reloading code has removed itself by then.

  • when an object's source is modified, only the object class's module should be updated (which contains the object body), and not the module with object's singleton-getter (which seems to be static boilerplate). -- When Vite sees an update to the object's class module, it will hot-reload it. When Vite sees an update to the object loader, it doesn't find anything hot-reloadable and instead reloads the entire page, which loses all user state.

Proposed Solution

In order for scalajs-react (and presumably some other libraries too) to support hot-reloading, and hot-reloading without a page reload especially, some Scala.js changes seem unavoidable. As an initial draft proposal, I suggest the following:

  • A new ability to Scala.js that
    • for some Scala objects (see the next section for a concrete example)
      • separate the object class into its own module as the sole, default export
      • eagerly initialise the object when the class module is loaded
      • allow for replacement of object instances
    • decides which Scala objects to transform by, at a minimum, inspecting the object body, probably looking for a term (eg. scalajs.js.special.eagerlyLoad;). (An annotation won't work because it needs to be generated in scalajs-react call-site. It needs to be something a macro can provide that users can store in a val.)
    • can be globally enabled/disabled via an sbt setting
    • will only work when modules are being emitted (maybe some other constraints?)
  • If the module emitting code doesn't already have this, teach it to remember previous output (at least checksums) and avoid replacing files with the exact same content

Sample Changes In Output

Consider this example Scala source code:

package demo

object MyComponent {
  org.scalajs.dom.console.log("MyComponent initialising...")
}

Currently it emits a single module:

demo.MyComponent$.js:

'use strict';
import * as $j_java$002elang$002eObject from "./java.lang.Object.js";

/** @constructor */
function $c_Ldemo_MyComponent$() {
  $n_Ldemo_MyComponent$ = this;
  console.log("MyComponent initialising...")
}
export { $c_Ldemo_MyComponent$ as $c_Ldemo_MyComponent$ };
$c_Ldemo_MyComponent$.prototype = new $j_java$002elang$002eObject.$h_O();
$c_Ldemo_MyComponent$.prototype.constructor = $c_Ldemo_MyComponent$;

/** @constructor */
function $h_Ldemo_MyComponent$() {
  /*<skip>*/
}
export { $h_Ldemo_MyComponent$ as $h_Ldemo_MyComponent$ };
$h_Ldemo_MyComponent$.prototype = $c_Ldemo_MyComponent$.prototype;
var $d_Ldemo_MyComponent$ = new $j_java$002elang$002eObject.$TypeData().initClass({
  Ldemo_MyComponent$: 0
}, false, "demo.MyComponent$", {
  Ldemo_MyComponent$: 1,
  O: 1
});
export { $d_Ldemo_MyComponent$ as $d_Ldemo_MyComponent$ };
$c_Ldemo_MyComponent$.prototype.$classData = $d_Ldemo_MyComponent$;

var $n_Ldemo_MyComponent$;
function $m_Ldemo_MyComponent$() {
  if ((!$n_Ldemo_MyComponent$)) {
    $n_Ldemo_MyComponent$ = new $c_Ldemo_MyComponent$()
  };
  return $n_Ldemo_MyComponent$
}

export { $m_Ldemo_MyComponent$ as $m_Ldemo_MyComponent$ };

Hot-reloading compatible output would look like this:

  • demo.MyComponent$.js:

    'use strict';
    import * as $j_java$002elang$002eObject from "./java.lang.Object.js";
    
    // ****** NOTE: This snippet is moved into another file
    import $c_Ldemo_MyComponent$ from "./demo.MyComponent.js"
    export { $c_Ldemo_MyComponent$ as $c_Ldemo_MyComponent$ };
    
    /** @constructor */
    function $h_Ldemo_MyComponent$() {
      /*<skip>*/
    }
    export { $h_Ldemo_MyComponent$ as $h_Ldemo_MyComponent$ };
    $h_Ldemo_MyComponent$.prototype = $c_Ldemo_MyComponent$.prototype;
    var $d_Ldemo_MyComponent$ = new $j_java$002elang$002eObject.$TypeData().initClass({
      Ldemo_MyComponent$: 0
    }, false, "demo.MyComponent$", {
      Ldemo_MyComponent$: 1,
      O: 1
    });
    export { $d_Ldemo_MyComponent$ as $d_Ldemo_MyComponent$ };
    $c_Ldemo_MyComponent$.prototype.$classData = $d_Ldemo_MyComponent$;
    
    // ****** NOTE: A variable and singleton-logic is removed in favour of a global var lookup.
    // ******       These objects are always eagerly initialised and will always exist.
    function $m_Ldemo_MyComponent$() {
      return globalThis.scalaJsEagerObjects["demo.MyComponent"];
    }
    
    export { $m_Ldemo_MyComponent$ as $m_Ldemo_MyComponent$ };
  • demo.MyComponent.js:

    'use strict';
    import * as $j_java$002elang$002eObject from "./java.lang.Object.js";
    
    /** @constructor */
    function $c_Ldemo_MyComponent$() {
      // ****** NOTE: Here we add/replace ourselves in a global var instead of a local one
      // ******       hidden in another module.
      (globalThis.scalaJsEagerObjects ||= {})["demo.MyComponent"] = this;
      console.log("MyComponent initialising...")
    }
    $c_Ldemo_MyComponent$.prototype = new $j_java$002elang$002eObject.$h_O();
    $c_Ldemo_MyComponent$.prototype.constructor = $c_Ldemo_MyComponent$;
    
    // ****** NOTE: Here we eagerly initialise object.
    // *****        Also replaces an older version on hot-reload.
    new $c_Ldemo_MyComponent$()
    
    // ****** NOTE: Here we have a single default export.
    export default $c_Ldemo_MyComponent$;

Who Does The Work?

I might be able to put my hand up to implement this. It seems about a medium-difficulty change to me, but we'd have to work out how long we think it would take before I can say much more.

I think it'd be a really good idea to work together with Team Scala.js to come up with a plan that we all agree is effective and feasible. With your blessings and guidance, I hope we can at least get a spec-lite ready.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementFeature request (that does not concern language semantics, see "language")

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions