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.
npx claudepluginhub zhanghandong/makepad-skills --plugin makepad-skillsThis skill uses the workspace's default tool permissions.
Makepad 2.0 uses a unique rendering pipeline combined with the Splash script VM. Performance depends on understanding three critical subsystems:
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
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