Guide
Hot reloading #
Hot module reloading (HMR) keeps your app running while you change UI code. Knots compiles each UI module to WebAssembly and replaces it in the running host. The window, the GPU state, and your data stay.
How it works #
- A host is your normal executable. It owns the window, the renderer, and the application state.
-
A module is a Zig file with a
pub fn main(frame: *knots.Frame). Each module compiles to its own small WASM binary. - The dev runner watches your source files. When a file changes, it compiles only the modules that changed. It publishes a new manifest after a successful build.
- The host loads the new module. A native host runs modules in Wasmtime. A browser host runs them in the WebAssembly engine of the browser.
If a build fails, the last working module stays in use. The host shows the compiler errors in the window.
Write a module #
Put modules in a directory, for example src/panels. Each
.zig file with a public main is a module. Files without
main are helpers that modules can import.
// src/panels/counter.zig
const std = @import("std");
const knots = @import("knots");
pub fn main(frame: *knots.Frame) !void {
const count = try frame.bindState(u32, "counter.count", 0);
try frame.e(.{
knots.component.Text{
.key = .src(@src()),
.content = try std.fmt.allocPrint(frame.arena(), "Count: {d}", .{count.*}),
},
});
if ((try frame.interact(knots.component.Button{ .key = .src(@src()), .label = "+1" })).clicked) {
count.* += 1;
frame.requestRedraw();
}
}
A module can import these modules:
-
knots—Frameandcomponent. This is the portable API. -
knots-ui— the fulluimodule:Style,Theme,Key,control, and more.
The module ID is the name of the root directory and the file path without
.zig. For example, src/panels/counter.zig has the ID
panels/counter. A module can also declare pub fn deinit().
The host calls it before it unloads the module.
Keep state across reloads #
A reload replaces the memory of the module, so global variables reset. Use
frame.bindState for values that must stay:
const theme_index = try frame.bindState(u32, "settings.theme", 1);
- The host stores the value under the name. The pointer is valid for this frame.
- Knots writes the value back to the host at the end of the frame.
-
The type must be 16 bytes or smaller. If the type for a name changes, the call
returns
error.StateSchemaMismatch. - Bound values are signals. The host records which modules read each value. When a value changes, the host renders only the modules that depend on it.
Keep large application data in the host. Give it to modules through bound state, or through normal host code that calls the module.
Configure the build #
HMR needs two executables: a development host and a release host. Make both from the
same code. Then connect them to Knots.HMR:
const Knots = @import("knots");
pub fn build(b: *std.Build) void {
// ... target, optimize, and the knots dependency ...
const exe = makeExe(b, knots, target, optimize, "app");
const dev_exe = makeExe(b, knots, target, optimize, "app-dev");
b.installArtifact(exe);
const hmr = Knots.HMR.init(b, dev_exe, .{
.knots = knots,
.roots = &.{b.path("src/panels")},
.watch_roots = &.{b.path("src")},
});
hmr.attachNative(exe);
const dev = hmr.addDevRunner(.{});
b.step("dev", "Run with hot reloading").dependOn(&dev.step);
}
| Call | Effect |
|---|---|
HMR.init(b, dev_exe, options) |
Finds the modules in roots. Adds the module runtime to
dev_exe. watch_roots are other directories whose
changes cause a rebuild.
|
hmr.attachNative(exe) |
Compiles the modules into exe as normal Zig code. The release build
has no WebAssembly runtime and no reload support.
|
hmr.addDevRunner(options) |
Returns a run step that starts the dev runner and the host. Options:
port (default 8000), build_arguments,
application_arguments, and the web file names.
|
HMR.init also adds two steps: knots-hmr-modules builds the
modules only, and hmr-check compiles the host, the runner, and the modules
without running them.
Render modules in the host #
After HMR.init, the knots import of the host also has
knots.Modules. Use it to list and render modules:
const Self = @This();
app: knots.App,
modules: *knots.Modules,
pub fn init(io: std.Io, gpa: std.mem.Allocator, environ_map: anytype) !Self {
var app = try knots.App.init(io, gpa, .{ .window = .{ .width = 1280, .height = 720, .title = "App" } });
errdefer app.deinit();
return .{ .app = app, .modules = try knots.Modules.create(gpa, io, environ_map) };
}
pub fn start(self: *Self) !void {
try self.modules.startWatching(.{ .context = self, .notify = wake });
try self.app.start(frame);
}
fn wake(context: *anyopaque) void {
const self: *Self = @ptrCast(@alignCast(context));
self.app.requestFrame(.main) catch {};
}
fn frame(view: *knots.View, context: *ui.Frame) !void {
const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
_ = try self.modules.list();
var index: u32 = 0;
while (index < self.modules.count()) : (index += 1) {
// self.modules.id(index) is "panels/counter", for example.
try self.modules.render(index, context);
}
}
-
Give
createthe environment map fromstd.process.Init. The dev runner uses environment variables to tell the host where it is. -
startWatchingcallsnotifywhen a module changes. Wake the app there. -
renderputs the output of the module in the current parent element. A frame can render a maximum of 30 modules. -
source(index)andgeneration(index)return the source text and the reload count of a module. - Call
modules.destroy()beforeapp.deinit().
Run it #
zig build dev
Edit a module and save it. The window updates after the module compiles. To use a browser host, build the development host for WebAssembly:
zig build dev -Dtarget=wasm32-freestanding
The dev runner then serves the web files on the port and the page reloads modules without a page reload.
Limits #
-
Modules use only the portable UI API. They cannot use the window, the renderer, the
GPU,
GPUCanvas, or native textures. - Knots composes the output of a module as a translated, clipped packet in the region of the module.
- A theme change in a module affects only that module. Keep the theme in the host.
- The native host uses Wasmtime. Prebuilt Wasmtime libraries exist for macOS ARM64, Linux x86-64, and Windows x86-64.
The
playground
example is a complete HMR host. Each demo in src/demos is a module.