zgx

Types, check and lint

Type check scripts with zgx check, catch costly patterns with zgx lint, and read what a reload reports.

Types

crates/zgx-script/types/zgx.d.luau types the ui and anime globals for luau-lsp: editors complete property names, events and API calls, and report misspelled ones (bgg, durtion, on_clik, state:sett) before the script runs. In VS Code, list the file under luau-lsp.types.definitionFiles and turn on Luau’s new type solver, which the types need:

{
    "luau-lsp.platform.type": "standard",
    "luau-lsp.types.definitionFiles": ["crates/zgx-script/types/zgx.d.luau"],
    "luau-lsp.fflags.override": { "LuauSolverV2": "true" }
}

In Zed, with the Luau extension, the repository’s .zed/settings.json does the same; for a project elsewhere, copy it and point definitions at the file.

Put "languageMode": "strict" in the project’s .luaurc (non-strict mode reports little), and the standard platform resolves require by path, so types flow between modules.

The types say what the engine accepts. Each element function takes its own props type (InputProps needs value, a State<string>; leaves such as input take no children; containers do), and every props and options table is closed, so an unknown key is an error: luau-lsp reports it as the key’s type not being number (Expected this to be 'number', but got '"bgg"'). String options are the values the engine knows (items = "centre" is an error). Events get their payloads (on_press a PointerEvent, on_key a KeyEvent, returning true to consume it). State<T> takes its initial value’s type: count:get() + 1 checks, count:set("x") does not, and a list state’s push takes its items; give nil and empty tables a type (ui.state("selected", nil :: string?), ui.state("tabs", {} :: { Tab })). A property bound to state takes any state whose value fits it.

The file’s export types are global type names for a script’s own annotations: Node, Child, State<T>, Props, ContainerProps, each element’s props type, Styles, Color, Element, Ref, and Merge<A, B> to make a props type of your own (reference). Where an argument is the script’s own data Luau cannot infer well, it is any (each’s row and presence’s value, drag payloads, a page’s messages): annotate the callback’s parameter, function(task: Task), to check it.

The definitions are written in types/zgx.d.luau.in, where element props are Merge<Props, { … }>; zgx.d.luau is that file with each Merge written out (luau-lsp finds that many type function calls too complex). tests/types.rs regenerates it (ZGX_BLESS=1 cargo test -p zgx-script --test tooling types::) and fails when it is stale, and fails when the engine accepts a style property, element option, event, ui or anime function, State, Element, Ref, Web, Term, Canvas or Animation method or field, or animatable key the types do not have. At run time an unknown property or animation property is an error too, shown over the window.

Checking before running: zgx check

cargo install --path crates/zgx-script --bin zgx   # or: cargo run -p zgx-script --bin zgx --
zgx check app.luau          # a file
zgx check examples/notes    # every .luau file under a directory

zgx check runs luau-lsp analyze with the definitions above built into the binary, the standard platform, the new solver (--flag:LuauSolverV2=true) and strict mode, unless a .luaurc (languageMode) or a --!nonstrict comment picks another mode (--nonstrict changes the default). It prints file:line:col: error[TypeError]: … and lint warnings, and exits 1 when there are errors (2 for usage errors, 3 when luau-lsp is missing). It catches what otherwise appears only at run time, such as a local function used before its definition, which is nil until called:

local function render()
    return ui.column { helper() } -- app.luau:2:24: error[TypeError]: Unknown global 'helper'
end
local function helper() return ui.text "hi" end

An unused local that a text template uses (local count = ui.state("count", 0) shown as "{count}") is not reported. luau-lsp comes from its releases (put it on the PATH, or set LUAU_LSP to it); without it, zgx check says how to install it. A hot-reloading host runs the same check after every reload and shows the errors in the window’s banner: in a debug build host.watch turns it on when luau-lsp is installed (a release build, or a machine without luau-lsp, does nothing). host.check_on_reload(true | false) or ZGX_CHECK=1 / 0 decides instead. The check runs luau-lsp in its own process from another thread, after a reload rather than per frame, and costs about 0.1 s of that process’s CPU for a twenty-module app; results arrive through host.watch.

Best practice: zgx lint

zgx lint app.luau           # a file
zgx lint examples/notes     # every .luau file under a directory
zgx lint --rules            # what it looks for

zgx check asks whether a script is right; zgx lint asks whether it is written the way zgx runs fast. It reads the source only (no luau-lsp, nothing run), takes milliseconds for a large app, prints file:line:col: warning[rule]: … with the cheaper way to say the same thing, and exits 1 when it finds anything. CI lints every example with it. Run it, with zgx check, before handing a script over:

RuleWhat it findsInstead
untracked-getstate:get() while an element is built (ui.text { "n: " .. n:get() }, ui.show(open:get(), …)): the interface is built once, untracked, so it shows that moment’s value foreverthe state itself, a template ("n: {n}") or a function (function() return n:get() end)
state-in-builderui.state("key", …) inside a component, each row or list row: one script-wide state that every instance sharesui.use_state(initial), or a key naming the instance (`file:{pane}`) when sharing by key is the point
timer-intervalui.every(ms) under 16 ms: more script calls than display framesanime or ui.tween (Rust, paced by the display), or the rate the value really changes
tick-tweenanime.animate in a ui.every callback running half the interval or more, uncapped: a 420 ms tween every 500 ms animates 84% of the time, laying out and painting on most frames for as long as the timer runsframeRate (30 or less), or a duration well under the interval
key-reads-statean each key function reading state: keys are kept per item value, so the key goes stale when the state changeskey on the item alone (item.id); read the state in the row
per-frame-stateonUpdate/onRender/onBeforeUpdate writing state with no frameRate: the script and every reader of the state run each display frameanimate the property, or the state as a target (anime.animate(state, { value = 1 })); or cap it (frameRate = 8 for a clock)
update-copystate:update(fn) that only inserts, removes or sets one path: it copies the whole value through Luaupush, insert, remove, set_key(path, v), patch
computed-sortui.sort in a ui.computed: every change to what it reads (a row written, a tick) sorts the whole list againui.sorted(list, { by, filter }), which re-places only the rows written
list-keya ui.list without key whose rows index into a list state the same file removes from or inserts into: rows are by position, so each removal or insertion writes every row after it with the item now there (its text, layout and paint again)key = function(i) return list[i].id end, the row reading its item by key: the rows a change shifts are moved, not written
list-measureda ui.list of fixed heights (height, heights) whose row wraps text (wrap = true with no lines cap, ui.markdown): how many lines it takes depends on its text and the width, which a script can’t measure, so rows are cut off or leave gapsmeasured = true: each row takes its content’s height as it mounts, height the estimate until then
canvas-viewa ui.canvas without view whose draw reads a zoom (view.zoom): every pan or zoom runs draw again, the script for every markdraw in the canvas’s own coordinates and give view = state ({ x, y, zoom }); bg_grid for a grid under it; what keeps its size on screen in a small canvas of its own
draw-item-statea ui.canvas draw_item reading state (:get()): each item’s drawing is kept until that item is written, so it shows what it read thenkeep what an item looks like in the item (its colour, whether it is chosen), or draw what depends on other state in draw or a canvas of its own

-- zgx-lint: allow(rule) on the finding’s line or the line above accepts a deliberate exception (allow(rule-a, rule-b) for several).

A debug build lints the scripts itself after every load and reload, so a findings list needs no one to remember to ask: each finding is printed once to stderr, kept in ScriptHost::warnings(), and shown over the interface (ScriptHost::lint_findings()) while there is no error. It costs milliseconds per reload, a release build or a bundled app does nothing, and ZGX_LINT=0 turns it off. For tools, zgx lint --format json prints { "files": n, "findings": [{ "file", "line", "column", "rule", "message" }] }; like the text form, it exits 1 when it finds anything.

Some costs the engine removes instead of asking scripts to avoid them: ui.tween, ui.spring, ui.loop, text templates and animations of states or elements run without calling the script at all.

Diagnostics

Errors in the window. When a script fails to load or reload, or a handler, computed property or effect raises, a banner over the bottom of the window shows the file and line (app.luau:12, in monospace), the message and as much of the traceback as fits. A failed reload keeps the previous interface running under it; the next good reload clears it, and × dismisses it. The banner is mounted only while there is an error (View::child_when), so without one it costs no nodes and no per-frame work. host.error() is the same message as a signal.

Layout inspector. Ctrl+Option+Cmd+I in a desktop window (Ctrl+Alt+Shift+I on Linux and Windows) toggles it, as does ZGX_INSPECT=1, cx.inspector.set_enabled(true) on a WindowContext, or host.inspect(true). It outlines every box and describes the one under the pointer: its kind, #id and .classes, the script line that created it (created at app.luau:42; inner nodes say created inside), size and position, explicit sizes and limits, padding, margin, border and gap, flex grow, shrink, alignment and justification, and its clip. It follows the pointer only; it never synthesizes input, and input and cursors pass through it. It is drawn as an overlay of ordinary scene nodes appended last under the window’s root, absolutely positioned with a fixed size: it takes no room in the interface’s layout and its own updates lay out only itself, and it renders through the same renderer (GPU, software, native-view planes) rather than a special pass. While it is on, scripts record where they create nodes (turning it on reloads them, keeping state); off, nothing is recorded and it holds no nodes.

Layout warnings. While the inspector is on, or with ZGX_LAYOUT_WARNINGS=1, each layout is checked for: a zero-width or zero-height box with children or text (not one collapsed on purpose with w = 0 or max_h = 0); in-flow content past the edges of a clipping box that does not scroll; a percentage width or height inside a parent sized by its content, which falls back to its content size. Each is reported once per node to stderr (zgx layout warning: app.luau:12: column #list: zero height (380 × 0) with 3 children; …) and listed by the inspector, which outlines the box in orange. Debug builds do not check by default: the walk is linear in the scene on every layout, which animations repeat each frame.

Edit on GitHub