Code hotswapping for Haxe generated JavaScript.
Hot swapping code is a complicated matter. It is fair to say that in general, it is strictly impossible. It can only be made to work in specific cases. So even when this library has made it past its experimental stages, it will impose quite significant restrictions to the code it's supposed to work with.
It will also always have performance implications.
When compiled with hotswap
all classes compiled with -D js-unflatten
and their names changed to hx.the$full$dotpath$ClassName
.
This means that all classes wind up in a variable called hx
, roughly like so:
var hx = {};
hx.Reflect = function() { };
hx.haxe$rtti$Meta = function() { };
The code swapping is primarily accomplished by assigning the value of hx
from the new code to the hx
variable present in the old code.
This has implications for some reflection APIs like Type.resolveClass
and Type.getClassName
. Their implementations are rewired to hide this difference. You may still notice it when looking at the generated JavaScript.
When your code executes for the first time, hotswap
does a few things:
-
Closure wrapping: For any method closures discovered at compile time, the method is wrapped into an alias, roughly like so:
var alias = 'hotswap.' + originalName; theClass.prototype[alias] = theClass.prototype[originalName]; theClass.prototype[originalName] = function () { return this[alias].apply(this, arguments) };
This is made so that
$bind
as used by Haxe to ensure method closures don't losethis
doesn't copy the implementation in an unreversible manner. -
Prototype Indirection: Prepend an empty
{}
into the prototype chain of every class. This is so that during reloadingObject.setPrototypeOf(oldClass.prototype, newClass.prototype)
will affect all existing instances of oldClass. -
further static initialization (as genereated by Haxe).
-
onHotswapLoad
Callbacks: Any class that definesstatic function onHotswapLoad(firstTime:Bool)
will have it invoked with a flag indicating whether the class is loaded for the first time (so during initialization it is alwaystrue
). -
main
entry point is called.
The relevant entrypoint for reloading is the following:
package hotswap;
class Runtime {
static public function createPatch(source:String):Outcome<{ function apply():Bool; }, Dynamic>;
}
If parsing the source fails, you get a corresponding Failure
, otherwise you get a patch, which you can apply
to swap in the new code (returning false
if you're trying to apply the same patch twice). Patching does a whole number of things. Note that the main
entry point of the new code is not called. On nodejs, the current file is automatically watched and reloaded.
-
the code is
eval
ed (unless it's the same as the last value passed tohotswap.Runtime.patch
), in a context where avar hxPatch = null
is available and assigns the value of itshx
"namespace" is assigned tohxPatch
. -
by virtue of being loaded, the loaded code will perform closure wrapping, prototype indirection and its own static initialization
-
any old class that defines
static function onHotswapUnload(stillExists:Bool)
will have it invoked with with a flag indicating if the class exists in the new code as well. -
the outer code keeps a reference to the previous value of
hx
and assigns the value stored inhxPatch
to it. From this point forward, all code points to the new classes. -
the empty prototypes of the old classes are pointed to the protypes of the new classes.
-
any writable
static var
orstatic dynamic function
in the new classes is assigned the value from the corresponding old class. The basic assumption here is that these values are meant to change at runtime and therefore the current runtime value trumps the initial value from the new code. -
onHotswapLoad
callbacks are executed, but thefirstTime
flag is now false for every class that existed in the previous code as well. -
hotswap.Runtime
defines astatic public var onReload(get, never):Signal<{ final revision:Int; }>
that fires accordingly.
Many things will simply not work as might be expected, or at least desired, but let's list a few.
Upon reload, they will point to the old classes. Meaning that even Std.is(someFoo, staleReferenceToFoo)
will yield false. Be inventive. Instead of passing around class references, pass around predicates, e.g.:
// Don't
var type:Class<Dynamic> = Foo;
// and later
if (Std.is(candidate, type)) {
trace('oh yeah!')
}
// Do
var typeChecker:Dynamic->Bool = v -> Std.is(v, Foo);
// and later
if (typeChecker(candidate))) {
trace('oh yeah!')
}
The second approach is also more flexible, because in essence it uses filter functions and those are composeable.
Upon reload, such code will not change, e.g.
document.addEventListener('click', function () {
trace('clicked');
});
If you change that anonymous function above and the code is reloaded, the change is not reflected (unless of course the document.addEventListener
section is reexecuted ... in that case make sure to cleanup event listeners in onHotswapUnload
)
Let's consider this example:
class Foo {
static function onHotswapLoad(firstTime:Bool)
if (firstTime)
document.addEventListener('click', function () {
rejoice();
});
static function rejoice()
trace('oh yeah, I just got clicked!!!!');
}
After the class is unloaded, the next click will lead to an attempt to call hx.Foo.rejoice()
and that'll throw Cannot read property 'rejoice' of undefined
.
class Foo {
public function new() {
document.addEventListener('click', handleClick);
}
function handleClick() {
trace('click');
}
}
Now let's suppose we changed it like so:
class Foo {
var clicks = [];
public function new() {
document.addEventListener('click', handleClick);
}
function handleClick(event) {
trace('click number ${clicks.push(even)}');
}
}
On the next click, this will fail, because clicks
never gets initialized (haxe in fact moves the initialization to the constructor, but the constructor is not executed on existing classes).
The way to avoid this, is to make the initialization of clicks
lazy:
class Foo {
var clicks(get, null):Array<MouseEvent>;
function get_clicks()
return if (clicks == null) clicks = [] else clicks;
// ...
There's potential for solving this implicitly via macros. Using @:build(hotswap.Macro.process())
will move initialization into such lazy getters.
// Before
class Foo {
var clicks(get, null):Array<MouseEvent>;
function get_clicks()
return if (clicks == null) clicks = [] else clicks;
public function new() {
document.addEventListener('click', handleClick);
}
function handleClick(event) {
trace('click number ${clicks.push(even)}');
}
}
// After
class Foo {
var clicks:Int;
function get_clicks()
return if (clicks == null) clicks = 0 else clicks;
public function new() {
document.addEventListener('click', handleClick);
}
function handleClick(event)
trace('click number ${++clicks}');
}
This is likely to lead to an array being incremented. Everything is possible in JavaScript, but the results are often undesireable.
Be very careful around it. Avoid __init__
like the plague too. Both are a great way to shoot yourself in the foot, because they may just not quite work as expected during reload. When in doubt:
- use lazy initialization
- move it into
onHotswapLoad
, as that gives you more context and therefore more control.