5.9 KiB
Yep — here’s the concrete picture, with the “no-Proxy” trampoline approach, and what it can/can’t do.
A concrete hot-reload example (with trampolines)
sprite.cell v1
// sprite.cell
var X = key('x')
var Y = key('y')
def proto = {
move: function(dx, dy) {
this[X] += dx
this[Y] += dy
}
}
var make = function(x, y) {
var s = meme(proto)
s[X] = x
s[Y] = y
return s
}
return {
proto: proto,
make: make
}
What the runtime stores on first load
Internally (not visible to Cell), you keep a per-module record:
module = {
scope: { X, Y, proto, make }, // bindings created by var/def in module
export_current: { proto, make }, // what the module returned
export_handle: null // stable thing returned by use() in hot mode
}
Now, in hot-reload mode, use('sprite') returns export_handle instead of export_current. Since you don’t have Proxy/getters, the handle can only be “dynamic” for functions (because functions are called). So you generate trampolines for exported functions:
// export_handle is a plain object
export_handle = stone({
// stable reference (proto is identity-critical and will be patched in place)
proto: module.scope.proto,
// trampoline (always calls the latest implementation)
make: function(...args) {
return module.scope.make.apply(null, args)
}
})
Note what this buys you:
- Anyone who cached
var sprite = use('sprite')keeps the samespriteobject forever. - Calling
sprite.make(...)always goes through the trampoline and hits the currentmodule.scope.make.
Reload to sprite.cell v2
Say v2 changes move and make:
def proto = {
move: function(dx, dy) {
// new behavior
this[X] = this[X] + dx * 2
this[Y] = this[Y] + dy * 2
}
}
var make = function(x, y) { ... } // changed too
return { proto, make }
Runtime reload sequence (safe point: between actor turns):
- Evaluate the new module to produce
new_scopeandnew_export. - Reconcile into the old module record:
-
varbindings: rebindold.scope.make = new.scope.make- (and any other
vars)
-
defbindings: keep the binding identity, but if it’s an object you want hot-updatable, patch in placeold.scope.proto.move = new.scope.proto.move- (and other fields on proto)
Now the magic happens:
- Existing instances
shave prototypeold.scope.proto(stable identity). - You patched
old.scope.proto.moveto point at the new function. - So
s.move(...)immediately uses the new behavior. - And
sprite.make(...)goes through the trampoline toold.scope.make, which you rebound to the newmake.
That’s “real” hot reload without Proxy.
“Module exports just a function” — yes, and it’s actually the easiest
If a module returns a function:
// returns a function directly
return function(x) { ... }
Hot-reload mode can return a trampoline function:
handle = stone(function(...args) {
return module.scope.export_function.apply(this, args)
})
On reload, you rebind module.scope.export_function to the new function, and all cached references keep working.
“Module exports just a string” — possible, but not hot-swappable by reference (without changing semantics)
If the export is a primitive (text/number/logical/null), there’s no call boundary to hang a trampoline on. If you do:
return "hello"
Then anyone who did:
def msg = use('msg') // msg is a text value
…is holding the text itself. You can’t “update” that value in place without either:
Option 1: Accept the limitation (recommended)
- Hot reload still reloads the module.
- But previously returned primitive exports don’t change; callers must call
use()again to see the new value.
This keeps your semantics clean.
Option 2: Dev-mode wrapping (changes semantics)
In hot-reload mode only, return a box/thunk instead:
- box:
{ get: function(){...} } - thunk:
function(){ return current_text }
But then code that expects a text breaks unless it’s written to handle the box/thunk. Usually not worth it unless you explicitly want “dev mode has different types”.
Best convention: if you want a reloadable “string export”, export a function:
var value = "hello"
return { get: function() { return value } }
Now get() is trampoline-able.
About var vs def on reload
You’re very close, just phrase it precisely:
-
var: binding is hot-rebindable On reload,old.scope[name] = new.scope[name]. -
def: binding identity is stable (const binding) On reload, you do not rebind the slot.
But: for def that points to mutable objects that must preserve identity (like prototypes), you can still patch the object’s fields in place:
- binding stays the same object
- the object’s contents update
That’s not “setting new defs to old defs”; it’s “keeping old defs, optionally copying new content into them”.
If you want to avoid surprises, make one explicit rule:
- “def objects may be patched in place during hot reload; def primitives are never replaced.”
One important consequence of “no Proxy / no getters”
Your trampoline trick only guarantees hot-reload for:
- exported functions (via trampolines)
- exported objects whose identity never changes (like
proto), because the handle can point at the stable old object
It does not guarantee hot-reload for exported scalars that you expect to change (because the handle can’t dynamically compute a property value).
That’s fine! It just becomes a convention: “export state through functions, export identity anchors as objects.”
If you keep those rules crisp in the doc, your hot reload story becomes genuinely robust and lightweight: most work is “rebind vars” + “patch proto tables” + “trampoline exported functions.” The rest is just conventions that make distributed actor code sane.