Hot reload
Sub-second .vel reloads with state preservation.
Vel’s hot reload uses a host + plugin model: the host process owns the window, the GPU surface, the event loop, and all reactive state. The plugin is a tiny dylib (or so/dll) that holds the generated widget tree. Saving a .vel file rebuilds only the plugin and dlopens the new one.
Result: drag state, scroll position, focus, signal values — every piece of framework state outlives the reload, because none of it lives in the plugin.
The watcher
./scripts/dev-hot.sh examples/showcase.vel
That script:
- Spawns
showcase_hot(the host binary) withVEL_PLUGIN_PATHpointing atbuild/libshowcase_plugin.dylib. - Watches
examples/showcase.veland any file ituses withfswatch. - On every save: invokes
velcto regenerate.vel.cpp, thencmake --build build --target showcase_plugin(an-O0plugin build, sub-second). On success, touches a sentinel file the host watches. - The host sees the sentinel change,
dlclose()s the previous plugin,dlopen()s the new one, calls the plugin’svel_plugin_entry()factory, and swaps in the new root widget.
What survives the reload
- Every
Signal<T>value held by a long-lived owning component - Scroll offsets in every
ScrollViewandVirtualList - Focus on text inputs
- Mid-gesture drag state on sliders (via
EventDispatcher::captureDrag) - Theme, route, and any
PersistentSignalvalue (those round-trip throughStorageanyway)
What doesn’t survive
- Local variables inside a
build()function — these are recomputed on every rebuild by design. - Newly added
@statedeclarations — if you add a new signal to a component, the reload creates it with its initial value. Pre-existing signals keep their current value via type-tagged matching by name. - ABI breaks — if you change the public layout of a class held by both host and plugin, the reload is unsafe. The framework hides this by exposing only opaque types across the boundary.
Plugin entry contract
Every plugin exports one symbol:
extern "C" std::unique_ptr<vel::Widget> vel_plugin_entry();
The host calls this after dlopen and passes the returned root into App::setRoot(). No framework symbols cross the boundary in the function signature — the only thing the host knows about the plugin is “give me a Widget.”
When the plugin won’t reload
If you’re editing framework code (framework/src/...), the plugin reload won’t pick that up — you’d need to rebuild libvel.dylib, and the host links it dynamically too. The watcher only triggers on .vel files. For framework changes, restart the host.
Why this works
Two design choices make this safe:
libvel.dylibis the single shared boundary. Host and plugin both link it. FreeType, Dawn, every other dep stays internal. The plugin doesn’t have its own copy of anything load-bearing.- Opaque types in public APIs.
vel::Font,vel::Image,PainterImpl— none of them expose their inner layout. ABI breaks within the framework don’t poison the plugin contract.
This is why removing Skia from libvel.dylib mattered for hot reload, not just binary size. With Skia gone, the boundary between host and plugin is tiny and auditable.