v0.1.0-alpha
官网Home
docs/reference/troubleshooting
参考 · DebuggingReference · Debugging

问题排查Troubleshooting

从构建错误、状态生命周期到渲染异常,按最短路径定位原因。From build errors to state lifetime to rendering glitches — the shortest path to the cause.

预计阅读 8 分钟8 min read

构建错误Build errors

invalid fingerprint

模板的指纹不能直接复用。删除 build.zig.zon 里的 .fingerprint 行,运行一次 zig build,把 Zig 输出的唯一值填回去。A template’s fingerprint can’t be reused. Delete the .fingerprint line in build.zig.zon, run zig build once, and paste the unique value Zig prints back in.

import of file outside module path

不要对框架内部文件直接执行 zig test。使用 zig build test-ui 等仓库定义的测试 step(见命令速查)。Don’t run zig test on framework-internal files directly. Use the repo’s test steps such as zig build test-ui (see Commands).

@import("ui") / @import("zenit_app") not found

确认 build.zig.zon 声明了 .zenit 依赖,并且创建 executable 之后调用了 zenit.attach(zenit_dep, exe)——它负责添加 ui / zenit_app 模块、编译 macOS 桥接并链接系统框架。Check that build.zig.zon declares the .zenit dependency and that you call zenit.attach(zenit_dep, exe) after creating the executable — it adds the ui / zenit_app modules, compiles the macOS bridges and links the system frameworks.

build API field mismatch

先运行 zig version。zenit 要求 Zig 0.15.2(build.zig.zon 的 minimum_zig_version),其他版本的 build API 不在支持范围。Run zig version first. zenit requires Zig 0.15.2 (minimum_zig_version in build.zig.zon); other versions’ build APIs aren’t supported.

-Dtest-mode=true has no effect

Zig 的依赖选项是隔离的。在 b.dependency("zenit", ...) 中显式转发 .@"test-mode" 与 .@"e2e-port",参考模板 templates/minimal-app/build.zig。Zig isolates dependency options. Forward .@"test-mode" and .@"e2e-port" explicitly in b.dependency("zenit", ...), as templates/minimal-app/build.zig does.

运行时与状态Runtime and state

error.StateNotFound

显式 ID 的 cx.handler(State, id, method) 在对应 state 创建之前被调用。优先改用 cx.bindState + cx.on:直接传状态指针,不再管理 ID。An explicit-id cx.handler(State, id, method) ran before that state existed. Prefer cx.bindState + cx.on: they pass the state pointer directly, so there are no ids to manage.

leaked ArrayList / HashMap at exit

bindState 的状态归 Cx 所有,Cx 释放时一起释放;如果 T 声明了 pub 的 deinit(self: *T),框架会自动调用它。泄漏通常意味着 deinit 不是 pub,或者资源本该跟随页面生命周期。写法见下一节。State from bindState is owned by the Cx and freed when the Cx is; if T declares a pub deinit(self: *T), the framework calls it automatically. A leak usually means deinit isn’t pub, or the resource should have followed a page’s lifetime. See the next section.

crash after switching pages

检查是否在全局状态里保存了上一个页面的 *Node 或 *Scope。页面 Scope dispose 之后这些指针全部失效,必须同时清掉。Look for a previous page’s *Node or *Scope kept in global state. Once the page’s Scope is disposed, those pointers are dead and must be cleared with it.

Invalid free after updating text

节点原有文本可能是节点自己持有的副本。不要把 getText() 拿到的 props 改了 content 再 setText 回去;用 try node.setTextContent(cx.allocator, "Updated"),它会复制字符串并正确标记所有权。The node’s current text may be a copy the node owns. Don’t edit content on props from getText() and setText them back; use try node.setTextContent(cx.allocator, "Updated"), which copies the string and marks ownership correctly.

状态清理的正确写法Cleaning up state correctly

在状态里保存 allocator 字段,让 pub deinit 自己完成清理。初始值不能已经持有资源——在 bindState 之后再分配。Keep an allocator field in the state and let a pub deinit do the cleanup. The initial value must not own resources yet — allocate after bindState.

state_deinit.zig
const Editor = struct {
    allocator: std.mem.Allocator,
    lines: std.ArrayList([]const u8) = .empty,

    // pub: the Cx calls this automatically when it frees the state.
    pub fn deinit(self: *Editor) void {
        self.lines.deinit(self.allocator);
    }
};

// The initial value must not own resources yet; allocate after binding.
const editor = try cx.bindState(Editor, .{ .allocator = cx.allocator });

只有两种情况才用 scope.onCleanup:清理函数不是 pub deinit,或者资源必须跟随某个 Scope(页面)而不是整个 Cx 的生命周期。Reach for scope.onCleanup only when the cleanup isn’t a pub deinit, or when the resource must follow a Scope (a page) rather than the whole Cx.

scope_cleanup.zig
// Resources that must follow a page (Scope), not the whole window (Cx).
const PageCache = struct {
    allocator: std.mem.Allocator,
    map: std.StringHashMapUnmanaged(u32) = .empty,

    // Not named `pub fn deinit`: the Cx would call it again and double-free.
    fn release(self: *PageCache) void {
        self.map.deinit(self.allocator);
    }
};

const cache = try cx.bindState(PageCache, .{ .allocator = cx.allocator });
try scope.onCleanup(PageCache, cache, PageCache.release);

布局与绘制Layout and drawing

state changed but the text did not

setText 本身会比较新旧签名,并按需标记 sizing / render dirty,不需要手动 markRenderDirty。文本没刷新通常是改了一份 TextProps 副本却没有调用 setText,或者显示的值没有订阅对应的 Signal。更推荐用 ui.textFmt(cx, scope, fmt, .{ signals }, props) 直接订阅 Signal / Memo。setText already compares old and new signatures and marks sizing / render dirty as needed — no manual markRenderDirty. Stale text usually means a TextProps copy was edited without calling setText, or the displayed value never subscribed to its Signal. Prefer ui.textFmt(cx, scope, fmt, .{ signals }, props) to subscribe to Signals / Memos directly.

some colors ignore a theme switch

boxStyled / textStyled 等构造器会挂 on_theme hook,cx.setTheme 只在 cx.root 子树上重放它们;普通节点和组件库的样式是 mount 时的快照。主题切换时重建相应组件树,或在 Effect 里订阅 cx.themeSignal(scope) 并更新样式;没挂在 cx.root 下的节点也不会被更新。boxStyled / textStyled and friends attach an on_theme hook, and cx.setTheme replays those only across the cx.root subtree; plain nodes and component styles are snapshots taken at mount. Rebuild those trees on a theme change, or subscribe to cx.themeSignal(scope) in an Effect and restyle; nodes not attached under cx.root aren’t updated either.

clicks fall through an overlay’s empty area

没有交互行为的纯视觉容器默认是 pass_through。在浮层根节点的样式扩展上设置 hit_behavior = .@"opaque"——或者直接使用自带 barrier 的 Modal / Sheet。Purely visual containers with no interaction default to pass_through. Set hit_behavior = .@"opaque" on the overlay root’s style extension — or use Modal / Sheet, which bring their own barrier.

a huge invisible hit area in the window

挂上 ui.devtools.overlay 检查命中的节点,并用 node.globalRect() 打印它的屏幕矩形。通常是父容器的 fill / grow 配置或绝对定位范围过大;浮层未显示时也要确认它已摘出命中测试——用 node.setDisplay(.none) 隐藏的子树会整棵退出布局、绘制、命中与 Tab 遍历。Attach ui.devtools.overlay to inspect the node being hit, and print its screen rect with node.globalRect(). The usual cause is a parent’s fill / grow sizing or an oversized absolute position; for hidden overlays, check that they are removed from hit-testing — a subtree hidden with node.setDisplay(.none) leaves layout, painting, hit-testing and Tab traversal as a whole.

opaque_overlay.zig
// Plain visual containers are pass-through by default.
// Make an overlay root swallow clicks on its empty area:
(try panel.style.ensureExtFallible(cx.allocator)).hit_behavior = .@"opaque";

仍然无法解决Still stuck

提交问题时至少附上:When you file an issue, include at least:

  • ✓

    版本:zenit revision、zig version 与 macOS 版本。Versions: the zenit revision, zig version and your macOS version.

  • ✓

    复现:最小复现代码与执行的 build step。Reproduction: a minimal repro and the build step you ran.

  • ✓

    输出:完整的错误输出,不要只截最后一行。Output: the complete error output, not just the last line.

  • ✓

    渲染问题:截图,以及 DevTools 中相关节点的尺寸。Rendering issues: a screenshot plus the relevant nodes’ sizes from DevTools.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30