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:

  1. Spawns showcase_hot (the host binary) with VEL_PLUGIN_PATH pointing at build/libshowcase_plugin.dylib.
  2. Watches examples/showcase.vel and any file it uses with fswatch.
  3. On every save: invokes velc to regenerate .vel.cpp, then cmake --build build --target showcase_plugin (an -O0 plugin build, sub-second). On success, touches a sentinel file the host watches.
  4. The host sees the sentinel change, dlclose()s the previous plugin, dlopen()s the new one, calls the plugin’s vel_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 ScrollView and VirtualList
  • Focus on text inputs
  • Mid-gesture drag state on sliders (via EventDispatcher::captureDrag)
  • Theme, route, and any PersistentSignal value (those round-trip through Storage anyway)

What doesn’t survive

  • Local variables inside a build() function — these are recomputed on every rebuild by design.
  • Newly added @state declarations — 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.dylib is 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.