From makepad-skills
Debugs and optimizes Makepad 2.0 UI performance via draw batching (new_batch), Splash VM garbage collection, and render triggers. Fixes invisible text, UI freezes, scroll stuttering.
How this skill is triggered — by the user, by Claude, or both
Slash command
/makepad-skills:makepad-2.0-performanceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Makepad 2.0 uses a unique rendering pipeline combined with the Splash script VM. Performance depends on understanding three critical subsystems:
Makepad 2.0 uses a unique rendering pipeline combined with the Splash script VM. Performance depends on understanding three critical subsystems:
new_batch: true matterson_render / .render() system that controls when sub-trees rebuildUnlike traditional retained-mode UI frameworks, Makepad uses an immediate-mode-inspired draw pipeline where widgets emit draw commands into a sorted batch list. Understanding this pipeline is essential for diagnosing invisible text, flickering, and performance regressions.
Makepad automatically batches consecutive draw calls that use the same shader into a single GPU draw call. This is a major performance optimization, but it has a critical side effect: draw order can be surprising.
Draw pipeline (simplified):
Widget tree: GPU batches (default):
View (bg shader) Batch 1: all bg shaders
Label (text) --> Batch 2: all text shaders
View (bg shader)
Label (text) Result: ALL backgrounds draw first,
then ALL text draws second
When a View has show_bg: true AND contains text children, the text can end up behind the background because both text draws get batched together into a single draw call that executes before (or after) the background draw calls.
new_batch: trueSetting new_batch: true on a View forces Makepad to start a new draw batch at that point. This creates a ViewOptimize::DrawList internally, which ensures proper draw ordering within that View's subtree.
// PROBLEM: Label text is invisible - batched behind the background
RoundedView{
width: Fill height: Fit
draw_bg.color: #1e1e2e
Label{text: "This text is INVISIBLE"}
}
// FIX: new_batch ensures background draws before text
RoundedView{
width: Fill height: Fit
new_batch: true
draw_bg.color: #1e1e2e
Label{text: "This text is VISIBLE"}
}
new_batch: true Is Required| Scenario | Required? | Why |
|---|---|---|
View with show_bg: true containing Labels | YES | Text batches behind background |
| View with hover animator + text children | YES | Hover bg covers text on activation |
| Container of repeated items with backgrounds | YES | Each item and the container need it |
Transparent View (no show_bg) with Labels | NO | No background to overlap |
| View with only non-text children (e.g., icons) | NO | Same shader type - no overlap issue |
| Deeply nested Views each with backgrounds | YES on each | Each background layer needs its own batch |
new_batchThis is the number one mistake with hoverable list items. When a View has show_bg: true with a hover animator that transitions from transparent (#0000) to opaque on hover, the text disappears on hover because the newly-opaque background covers the batched text.
// CORRECT: Hoverable item with new_batch
let HoverItem = View{
width: Fill height: Fit
new_batch: true
show_bg: true
draw_bg +: {
color: uniform(#0000)
color_hover: uniform(#fff2)
hover: instance(0.0)
}
animator: Animator{
hover: {
default: {
from: {all: Forward{duration: 0.1}}
apply: {draw_bg: {hover: 0.0}}
}
on: {
from: {all: Forward{duration: 0.1}}
apply: {draw_bg: {hover: 1.0}}
}
}
}
label := Label{text: "item" draw_text.color: #fff}
}
// Parent container of hover items also needs new_batch
RoundedView{
flow: Down height: Fit new_batch: true
draw_bg.color: #2a2a3d
draw_bg.border_radius: 8.0
HoverItem{label.text: "First item"}
HoverItem{label.text: "Second item"}
}
The new_batch and texture_caching properties map to a ViewOptimize enum:
ViewOptimize::None - Default. No special draw ordering.
ViewOptimize::DrawList - Created by new_batch: true. Starts a new DrawList2d.
ViewOptimize::Texture - Created by texture_caching: true. Renders to offscreen texture.
Priority: texture_caching takes precedence over new_batch if both are set.
Setting texture_caching: true on a View renders its entire child sub-tree to an offscreen GPU texture. On subsequent frames, if nothing in the sub-tree has changed, Makepad can skip re-rendering the children and just blit the cached texture.
// Cache a complex but rarely-changing sidebar
sidebar := View{
width: 280 height: Fill
texture_caching: true
flow: Down spacing: 4
// ... many child widgets ...
}
Makepad provides pre-styled cached views:
| Widget | Description |
|---|---|
CachedView | Texture-cached rectangle container |
CachedRoundedView | Texture-cached rounded rectangle |
Good candidates:
Bad candidates:
| Benefit | Cost |
|---|---|
| Reduces per-frame draw call count | Uses GPU memory for cached texture |
| Avoids re-traversing large sub-trees | Texture must be invalidated on change |
| Can eliminate batching issues (the texture resolves draw order) | DPI factor affects texture resolution |
The Splash VM uses a mark-and-sweep garbage collector with isolated heaps for different value types:
Heap Layout:
+-- Objects (ScriptObject) -- Primary allocation type
+-- Arrays (ScriptArray) -- Typed arrays and value arrays
+-- Strings (ScriptString) -- Interned strings
+-- Pods (ScriptPod) -- Pod values (vec2, vec3, vec4, etc.)
+-- Handles (ScriptHandle) -- Native Rust handles
+-- Regexes (ScriptRegex) -- Interned regex patterns
The GC uses a growth-based heuristic similar to Lua and V8:
| Category | Minimum Before GC Can Trigger |
|---|---|
| Objects | 1,024 |
| Strings | 256 |
| Arrays | 128 |
| Pods | 128 |
| Handles | 64 |
GC triggers when: current_count >= MIN_THRESHOLD AND current_count >= last_gc_count * 2
mod.gc.run() - Force a GC cycle immediately. Silent (no log output).
mod.gc.run_status() - Force a GC cycle and print detailed statistics:
GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15] ...
Where S=static (permanent), A=alive (survived), R=removed (freed).
mod.gc.set_static(value) - Mark a value and its entire reachable object graph as static. Static objects:
mod.gc.dump_tag(value) - Debug tool. Prints internal tag information for an object: type index, static flag, proto chain.
Pattern: Static UI Trees
For large, stable UI tree definitions (like a Dock with many tabs), mark them as static immediately after definition. This is the standard pattern used in the Studio and UIZoo examples:
// Define a large widget tree
let AppDock = Dock{
// ... tabs, splitters, content templates ...
TabEditor := TabEditor{}
TabFileTree := TabFileTree{}
TabSettings := TabSettings{}
}
// Mark the entire tree as static - it will never be GC'd
mod.gc.set_static(AppDock)
// Run GC immediately to clean up any temporaries from tree construction
mod.gc.run()
// Now start the app
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
body +: {
// ... use AppDock here ...
}
}
}
}
Pattern: Dynamic Content
For dynamic content (lists, user-generated items, chat messages), let the automatic GC handle cleanup:
// Dynamic data - no need to call mod.gc manually
var todos = []
fn add_todo(text) {
todos.push({text: text done: false})
ui.main_view.render()
// Automatic GC will clean up old unreachable objects
}
fn delete_todo(index) {
todos.splice(index, 1)
ui.main_view.render()
// Old todo object becomes unreachable, will be collected automatically
}
Pattern: Periodic Manual GC for Long-Running Apps
For apps that create and destroy many objects (e.g., chat applications with streaming responses):
var message_count = 0
fn on_new_message(msg) {
messages.push(msg)
message_count += 1
// Every 100 messages, run GC to reclaim temporary parsing objects
if message_count % 100 == 0 {
mod.gc.run()
}
ui.message_list.render()
}
The mark phase traverses from roots:
ScriptObjectRef)ScriptArrayRef)ScriptHandleRef)Static objects are skipped during traversal since they only reference other static values.
on_render / .render() SystemMakepad 2.0 uses a pull-based rendering model for dynamic content. The on_render callback on a View only executes when .render() is called on that View.
// Define a reactive view
counter_view := View{
on_render: || {
Label{
text: "Count: " + state.counter
draw_text.color: #fff
}
}
}
// In event handler - only re-render what changed
fn increment() {
state.counter += 1
ui.counter_view.render() // Only this view re-renders
}
NEVER call .render() unnecessarily - Each call completely rebuilds that sub-tree's widget output.
Render only affected sub-trees - If only a list changed, render only the list view, not the entire UI.
Avoid rendering in tight loops - Batch state changes, then render once:
// BAD: renders 100 times
for i in 0..100 {
items[i].value = compute(i)
ui.item_list.render() // WASTEFUL - rebuilds list 100 times
}
// GOOD: render once after all changes
for i in 0..100 {
items[i].value = compute(i)
}
ui.item_list.render() // Render once with all changes applied
on_startup for initial render - Trigger the first render when the app starts:ui: Root{
on_startup: || {
ui.main_view.render()
}
main_window := Window{
body +: {
main_view := View{
on_render: || {
// ... dynamic content ...
}
}
}
}
}
When .render() is called on a View, only that View's on_render callback executes. Child Views with their own on_render callbacks will NOT automatically re-render unless their .render() is also called (or they are reconstructed by the parent's on_render).
Use the log! macro from Makepad's error log system:
use makepad_widgets::*;
// In Rust code
log!("Button clicked, counter = {}", self.counter);
log!("Widget action: {:?}", action);
In Splash scripts, you can use log() or string interpolation for debugging:
fn handle_click() {
let value = compute_something()
// Log values during development
log("computed value: " + value)
}
Use mod.gc.run_status() to get a detailed breakdown of GC activity:
// Output example:
// GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15]
// hdl[S:8 A:2 R:0] pod[S:200 A:45 R:10] rex[S:3 A:0 R:0]
Fields:
For deep debugging of specific objects, use mod.gc.dump_tag(value):
let my_widget = View{...}
mod.gc.dump_tag(my_widget)
// Output: obj 4523 type_index=Some(12) is_static=false proto=Some(89) ...
| Issue | Cause | Fix |
|---|---|---|
| Text invisible | Missing new_batch | Add new_batch: true to parent View with show_bg: true |
| Text disappears on hover | Batch overlap during hover animation | Add new_batch: true to the hoverable View |
| UI freezes / stutters | Excessive .render() calls | Batch state changes, render only changed sub-trees |
| Memory growing unbounded | GC not running or large static leaks | Use mod.gc.set_static() for stable trees, let auto GC handle dynamic content |
| Slow initial load | Large script evaluation at startup | Split into modules, use lazy loading patterns |
| Scroll stuttering | Too many items rendering | Use PortalList for virtualized rendering |
| Hover not responding | View missing show_bg: true | Views need show_bg: true to receive mouse events for hover |
| Widget not found at runtime | Wrong naming operator | Use := (not :) for named/addressable children |
| Style overrides not applying | Missing merge operator | Use +: to merge properties, not : which replaces entirely |
| Layout collapsed to zero | Missing height: Fit | All containers need explicit height: Fit or a fixed height |
PortalList virtualizes rendering -- only items visible in the viewport are drawn. This is mandatory for lists with 100+ items. Without it, all items are drawn every frame regardless of visibility.
For Splash-driven lists, define the PortalList with templates and use on_render:
list := PortalList{
width: Fill height: Fill
flow: Down spacing: 4
scroll_bar: ScrollBar{}
Item := View{
width: Fill height: Fit
padding: 8
new_batch: true
draw_bg.color: #2a2a3d
label := Label{text: "" draw_text.color: #ddd}
}
}
For Rust-driven rendering, implement the Widget trait:
impl Widget for MyList {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
if let Some(mut list) = item.borrow_mut::<PortalList>() {
list.set_item_range(cx, 0, self.items.len());
while let Some(item_id) = list.next_visible_item(cx) {
let template = id!(Item);
let item = list.item(cx, item_id, template);
item.label(ids!(label)).set_text(cx, &self.items[item_id].text);
item.draw_all(cx, &mut Scope::empty());
}
}
}
DrawStep::done()
}
}
| Feature | FlatList | PortalList |
|---|---|---|
| Virtualization | No | Yes |
| Suitable for | < 100 items | Any number of items |
| Memory usage | All items in memory | Only visible items |
| Scroll performance | Degrades with count | Constant |
| Property | ViewOptimize Value | Effect |
|---|---|---|
| (default) | None | Standard batched drawing |
new_batch: true | DrawList | New draw batch, proper draw ordering |
texture_caching: true | Texture | Render children to offscreen texture |
visible: false | N/A | Skip rendering entirely |
Priority: If both texture_caching and new_batch are set, texture_caching wins (becomes ViewOptimize::Texture).
height: Fit on all containers (default height: Fill inside a Fit parent = 0 height)width: Fill on root container (never use fixed pixel width on outermost element)use mod.prelude.widgets.* is at the top of the scriptnew_batch: true to any View with show_bg: true that contains textdraw_text.color is not transparent or same as backgroundnew_batch: true to the hoverable Viewnew_batch: true:= vs : -- use := for named/dynamic children you referenceshow_bg: true is set for Views that need mouse eventsgrab_key_focus if keyboard events are needed#(WidgetName::register_widget(vm)) registration in script_modcrate::makepad_widgets::script_mod(vm) is called before custom registrationslog() to debug values during executionmod.gc.run_status() to check heap statisticsmod.gc.dump_tag(value) to inspect object internals+: merge operator for extending existing styles: draw_bg +: { color: #fff }: only when you want to fully replace a propertydraw_bg.color: #fff is shorthand for draw_bg +: { color: #fff }Makepad Studio includes a built-in profiler for monitoring application performance.
Studio can connect to running applications and provide:
cargo run -p cargo-makepad --release -- studio --studio=127.0.0.1:8001
{"Run":{"mount":"makepad","process":"makepad-example-myapp","args":[]}}
{"WidgetTreeDump":{"build_id":BUILD_ID}}
{"Screenshot":{"build_id":BUILD_ID}}
WidgetTreeDump to see how many widgets are in the treetexture_cachingPortalList instead of manual loopsnew_batch: true that might not need it (each new batch = new draw list)// Force new GPU draw batch (fixes text-behind-background)
new_batch: true
// Cache children to GPU texture (reduces draw calls for stable subtrees)
texture_caching: true
// Hide without removing from tree (skip rendering entirely)
visible: false
mod.gc.set_static(value) // Mark value tree as permanent
mod.gc.run() // Force GC cycle (silent)
mod.gc.run_status() // Force GC cycle with log output
mod.gc.dump_tag(value) // Debug: print object tag info
ui.widget_name.render() // Trigger on_render for specific widget
Objects: >= 1024 AND >= 2x since last GC
Strings: >= 256 AND >= 2x since last GC
Arrays: >= 128 AND >= 2x since last GC
Pods: >= 128 AND >= 2x since last GC
Handles: >= 64 AND >= 2x since last GC
npx claudepluginhub zhanghandong/makepad-skills --plugin makepad-skillsTroubleshoots Makepad 2.0 pitfalls like zero-height containers, invisible text/UI, widget rendering failures, hot reload issues, WASM build errors, and event handling bugs.
Generates and explains Makepad widget code (View, Button, Label, TextInput, Image) with styling patterns. Loads local reference docs for core, advanced, and rich-text widgets.
Generates Makepad widget code for View, Button, Label, TextInput, Image and explains properties, variants, composition. For Rust UI apps using makepad-widgets crate.