113

A fast React Live Editing implementation

 6 years ago
source link: https://www.juptr.io/juptrblogs/_57034b5b-fa76-4ef3-b6a8-7c715ee4f1a8.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

A fast React Live Editing implementation

A fast React Live Editing implementation

How millisecond-level hot reloading of a React WebApp can be implemented ..

Test

Hi folks, part of my personal mission to integrate good ol' Java with modern JavaScript frameworks was the implementation of "Hot Reload". I am not talking of "automatic Browser refresh" but of live patching the code of a React client without losing component state.

Demo:

Detecting file changes ..

This was the easy part: As Http4k parses all jsx import's, resolves and downloads dependencies from the npm repository, it has the knowledge which files are actually required by front end. So just build a simple FileWatcher and publish file changes using a web socket:

public class FileWatcher extends Actor<FileWatcher> {

    List<WatchedFile> watched = new ArrayList<>();
    List<Callback> watchers = new ArrayList<>();

    public IPromise addListener(Callback fileWatcher) {
        watchers.add(fileWatcher);
        return resolve();
    }

    public void startWatching() {
        cyclic(30, () -> {
            watched.forEach( watchedFile -> {
                Long l = watchedFile.getLastModified();
                if ( l != watchedFile.getFile().lastModified() ) {
                    watchedFile.updateTimeStamp();
                    watchedFile.transpiler.updateJSX(
                      watchedFile.getFile(),
                      watchedFile.resolver
                    );
                    fireChange(watchedFile.getWebPath());
                }
            });
            return !doStop;
        });
    }

    private void fireChange(String webPath) {
        watchers = watchers.stream().filter( cb -> ! cb.isTerminated() ).collect(Collectors.toList());
        watchers.forEach( w -> w.pipe(webPath));
    }

    // .. methods to register files
}

... expose it on a web socket so the javascript client can subscribe:

FileWatcher fileWatcher = AsActor(FileWatcher.class);
websocket("/hotreloading", fileWatcher)
                .serType(SerializerType.JsonNoRef)
                .buildWebsocket();

then generate some code into the js-client in order to listen to file changes:

  // subscribe to filewatcher
  kclient.connect(addr,"WS").then( (conn, err) => {
    if ( err ) {
      // ....
      return;
    }
    conn.ask("addListener", (libname,e) => {
      console.log("a file has changed _appsrc/"+libname);
      // handle module reload
    });
  });

Reloading the module ..

In order to keep the state of a React Component, its class must not change. Just creating a "new version" (e.g. using eval) of a class leads to React unmounting and remounting the patched component as the diff algorithm compares by checking "oldcomponent.__proto === newcomponent.__proto" (conceptually). Thereby all state of the patched component is lost.
Other implementations of hot-reload address this by generating "Proxy"-classes (in order to keep the reference '===' but swap actual implementation dynamically). The downside of this approach is its invasiveness and complexity. Due to the dynamic nature of JavaScript, static analysis of JavaScript code is notoriously unreliable and error prone.

But why not profit from the dynamic nature of JavaScript and do all this at runtime ? Actually one can "monkey-patch" the prototype of a JavaScript class at runtime, so I came up with the following scheme:
  • reload the modified module and run it using eval
  • get the "old" version of the module and patch the prototype of all top level classes
  • as functions of the newly loaded module might reference 'different' (in sense of ===) definitions of constants, classes, HOC's and functions, get the source of the methods and again 'eval' them in the scope of the original 'old' module.

This way proxying can be avoided completely, still code changes of classes are applied to old instances.

redefineModule = function (patch, prev, libname) {
    Object.getOwnPropertyNames(patch).forEach(topleveldef => {
      if (typeof patch[topleveldef] === 'function') {
        let src = patch[topleveldef].toString();
        // anyone has a better idea on how to detect es6 classes ?
        const isclass = src.indexOf("class") == 0; 
        if (isclass) {
          const newDef = __keval[libname](topleveldef + "=" + src + "; " + topleveldef);
          Object.getOwnPropertyNames(newDef.prototype).forEach(key => {
            prev[topleveldef].prototype[key] = newDef.prototype[key];
          });
        }
      }
     _kreactapprender.forceUpdate();
  };

to get the correct scope for the re-evaluation of a module ('_keval' above), at the bottom of each module  a '_keval' function is generated like:

 __keval = source => {
        var MyApp = _exports['MyApp'];
        var App = _exports['App'];
        var __kdefault__ = App;
        return eval(source.toString());
    }

This ensures that references inside the reloaded module's source reference the original instances (classes, HOCS, functions, objects). So basically we never change instances but only patch their implementation / prototype.
The __keval function lists all top level definitions of a module.

Patching HOC's / functions

Top-Level Functions are wrapped in order to get an additional level of indirection:
_kwrapfn = function(fn){
  const f = function(){
    return f._kwrapped.apply(this, arguments);
  };
  f._kwrapped = fn;
  return f;
}

We now can update the implementation of a function by updating the '_kwrapped' attribute. So the 'redefineModule' function above is extended to:

redefineModule = function (patch, prev, libname) {
    Object.getOwnPropertyNames(patch).forEach(topleveldef => {
      if (typeof patch[topleveldef] === 'function') {
        let src = patch[topleveldef].toString();
        const isclass = src.indexOf("class") == 0;
        const isfun = src.indexOf("function") == 0;
        if (isfun || !isclass) // assume function or lambda
        {
          let funsrc = patch[topleveldef]._kwrapped.toString();
          let evalSrc = "" + topleveldef + " = " + funsrc + ";" + topleveldef;
          const newfun = __keval[libname](evalSrc);
          prev[topleveldef]._kwrapped = newfun;
        } else if (isclass) {
          // see above
        }
     }
     _kreactapprender.forceUpdate();
  };

Patching Objects

Objects can be patched easily, just do an Object.assign( oldInstance, newInstance ). Unfortunately this fails in the following edge case:
const myinternalfun = x => <MyComp/>;
const Settings {
  comp: myinternalfun
}

In this case 'comp:' will reference a newly created function (not the wrapped one) and in turn 'MyComp' will point to a new, different class compared to the previous 'MyComp' class, so all state of 'MyComp' components will be lost. In order to avoid this, it would be required to obtain the source of the 'Settings' declaration and eval it in the scope of the originally loaded module :).

In case an object is supposed to hold module state it can be excluded from updating by setting a property '_kNoHMR'.

Git Repo with Demo and more docs:

Why is this quick & how does it scale ?

As kontraktor-http in development mode does not bundle files, just one modified module needs to get reloaded+reevaluated, so there is no performance degradation regardless of the size of an application. 
As web server, bundler and transpiler are kind of integrated, re-transpilation of the modified module can be done very fast, the call context of the "transpile" step is saved in memory making it possible to re-transpile a single module in a snap.

Downsides ?

Erm .. setting breakpoints in the browser does not work for eval'ed code. You need to set breakpoints from your editor by inserting a 'debugger;' statement. Still, imo it's a reasonable feature tradeoff (especially for larger webapps, where reloading requires a  login and several steps just in order to get visual feedback of a small source change).


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK