
Makepad 2.0 Performance
- 43 installs
- 745 repo stars
- Updated April 7, 2026
- zhanghandong/makepad-skills
Helps with ai & agent building tasks during AI-assisted development.
About
makepad-2.0-performance is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- makepad-2.0-performance
- AI & Agent Building
- AI-coding skill
Makepad 2.0 Performance by the numbers
- 43 all-time installs (skills.sh)
- +1 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7,921 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zhanghandong/makepad-skills --skill makepad-2.0-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 745 |
| Last updated | April 7, 2026 |
| Repository | zhanghandong/makepad-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Makepad 2.0 Performance & Debugging Skill
1. Overview
Makepad 2.0 uses a unique rendering pipeline combined with the Splash script VM. Performance depends on understanding three critical subsystems:
1. Draw Batching - How Makepad groups GPU draw calls and why new_batch: true matters 2. Garbage Collection - The Splash VM's mark-sweep GC with per-type-bucket thresholds 3. Render Triggers - The on_render / .render() system that controls when sub-trees rebuild
Unlike 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.
---
2. Draw Batching System
How It Works
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 secondWhen 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: true
Setting 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"}
}When 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 |
Hover Effects and new_batch
This 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"}
}ViewOptimize Internals
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.
---
3. Texture Caching
How It Works
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 ...
}Pre-Built Cached Views
Makepad provides pre-styled cached views:
| Widget | Description |
|---|---|
CachedView | Texture-cached rectangle container |
CachedRoundedView | Texture-cached rounded rectangle |
When to Use Texture Caching
Good candidates:
- Complex static sidebars or toolbars that rarely change
- Large widget sub-trees with many nested backgrounds and text
- Decorative panels with shader effects
Bad candidates:
- Frequently updating views (e.g., animation targets, live data)
- Small simple views (overhead exceeds benefit)
- Views that contain scrolling content (CachedView wraps the whole content, not the viewport)
Trade-offs
| 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 |
---
4. Garbage Collection (mod.gc)
Architecture
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 patternsAutomatic GC Triggering
The GC uses a growth-based heuristic similar to Lua and V8:
- Growth Factor: 2x - GC triggers when any heap category doubles since last GC
- Minimum Thresholds (to avoid thrashing on small heaps):
| 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
Script API
`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:
- Are never collected by GC (permanent)
- Are skipped during GC mark phase (faster GC traversal)
- Cannot be un-marked (irreversible within the VM lifetime)
`mod.gc.dump_tag(value)` - Debug tool. Prints internal tag information for an object: type index, static flag, proto chain.
Best Practices
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()
}GC Mark Phase Details
The mark phase traverses from roots: 1. Type check prototypes 2. Type defaults objects 3. Pod type defaults and objects 4. Root objects (held by Rust via ScriptObjectRef) 5. Root arrays (held by Rust via ScriptArrayRef) 6. Root handles (held by Rust via ScriptHandleRef) 7. Thread stacks (all live values on VM execution stacks) 8. Thread scopes 9. Method call contexts 10. Loop source values 11. Trap error/return/bail values 12. Script body scopes and tokenizer string literals 13. Native type table objects
Static objects are skipped during traversal since they only reference other static values.
---
5. Render Optimization
The on_render / .render() System
Makepad 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
}Rules for Efficient Rendering
1. NEVER call `.render()` unnecessarily - Each call completely rebuilds that sub-tree's widget output.
2. Render only affected sub-trees - If only a list changed, render only the list view, not the entire UI.
3. 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 applied4. Use `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 ...
}
}
}
}
}Render Scope
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).
---
6. Debug Logging
Rust-Side Logging
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);Script-Side Logging
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)
}GC Status Logging
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:
- Time (142us) - GC cycle duration in microseconds
- S (Static) - Objects permanently marked, never collected
- A (Alive) - Objects that survived this GC cycle
- R (Removed) - Objects freed in this cycle
Tag Debugging
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) ...---
7. Common Performance Issues & Fixes
| 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 |
---
8. PortalList for Large Lists
Why PortalList
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.
Script-Side PortalList (with on_render)
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}
}
}Rust-Side PortalList (with Widget trait)
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()
}
}FlatList vs PortalList
| 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 |
---
9. ViewOptimize Options Summary
| 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).
---
10. Debugging Checklist
UI Not Showing
- [ ] Check
height: Fiton all containers (defaultheight: Fillinside aFitparent = 0 height) - [ ] Check
width: Fillon root container (never use fixed pixel width on outermost element) - [ ] Verify
use mod.prelude.widgets.*is at the top of the script
Text Invisible
- [ ] Add
new_batch: trueto any View withshow_bg: truethat contains text - [ ] Check
draw_text.coloris not transparent or same as background - [ ] Verify the Label is a direct or properly-batched child
Text Disappears on Hover
- [ ] Add
new_batch: trueto the hoverable View - [ ] Ensure the container of hoverable items also has
new_batch: true
Clicks Not Working
- [ ] Check
:=vs:-- use:=for named/dynamic children you reference - [ ] Verify
show_bg: trueis set for Views that need mouse events - [ ] Check
grab_key_focusif keyboard events are needed
Widget Not Found
- [ ] Verify
#(WidgetName::register_widget(vm))registration in script_mod - [ ] Check that
crate::makepad_widgets::script_mod(vm)is called before custom registrations - [ ] Verify widget crate is a dependency in Cargo.toml
Script Errors
- [ ] Use
log()to debug values during execution - [ ] Use
mod.gc.run_status()to check heap statistics - [ ] Use
mod.gc.dump_tag(value)to inspect object internals
Style Not Applying
- [ ] Use
+:merge operator for extending existing styles:draw_bg +: { color: #fff } - [ ] Use
:only when you want to fully replace a property - [ ] Check dot-path syntax:
draw_bg.color: #fffis shorthand fordraw_bg +: { color: #fff }
---
11. Profiling with Studio
Makepad Studio includes a built-in profiler for monitoring application performance.
Studio Remote Protocol
Studio can connect to running applications and provide:
- Screenshots - Capture current frame state
- Widget Tree Dumps - Inspect the live widget hierarchy
- Widget Queries - Find specific widgets by ID
- Performance Monitoring - Frame times, draw call counts
Using Studio for Performance Debugging
1. Start Studio remote:
cargo run -p cargo-makepad --release -- studio --studio=127.0.0.1:80012. Run your app through Studio:
{"Run":{"mount":"makepad","process":"makepad-example-myapp","args":[]}}3. Capture widget tree to identify render structure:
{"WidgetTreeDump":{"build_id":BUILD_ID}}4. Take screenshots to verify visual state:
{"Screenshot":{"build_id":BUILD_ID}}Identifying Hot Render Paths
- Use
WidgetTreeDumpto see how many widgets are in the tree - Look for deeply nested View hierarchies that could benefit from
texture_caching - Identify repeated items that should use
PortalListinstead of manual loops - Check for Views with
new_batch: truethat might not need it (each new batch = new draw list)
---
12. Quick Reference Card
Performance Properties on View
// 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: falseGC API
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 infoRender API
ui.widget_name.render() // Trigger on_render for specific widgetGC Thresholds (Automatic Trigger)
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 GCMakepad 2.0 Optimization Guide
1. Draw Batching Deep Dive
The Batching Pipeline
Makepad's renderer collects draw commands from the widget tree and sorts them by shader type. This batching dramatically reduces GPU state changes but introduces draw ordering constraints.
WIDGET TREE DEFAULT BATCH ORDER
============ ===================
RoundedView (bg shader) Batch 1: bg shaders
+-- Label (text shader) RoundedView bg
+-- Icon (icon shader) SolidView bg
SolidView (bg shader)
+-- Label (text shader) Batch 2: text shaders
Label "Hello"
Label "World"
Batch 3: icon shaders
Icon
RESULT: Both backgrounds draw FIRST, then ALL text draws on top.
This works fine when backgrounds don't overlap with text
from OTHER containers.The Overlap Problem
The problem arises when sibling Views have backgrounds AND text. Without new_batch, a Label inside the second View gets batched with the first View's Label, potentially rendering behind the second View's background:
WITHOUT new_batch: WITH new_batch:
================= ================
Draw order: Draw order:
1. View-A background 1. View-A background
2. View-B background 2. View-A text "Hello"
3. Text "Hello" (View-A) --- new batch ---
4. Text "World" (View-B) 3. View-B background
4. Text "World" (View-B)
Problem: "World" draws
AFTER View-B's background, Each View is self-contained.
but "Hello" also draws after Text always renders on top of
View-B bg. If View-B bg is its own parent's background.
opaque, it covers "Hello".Batch Boundary Visualization
Here is how new_batch: true creates draw boundaries in a real widget tree:
RoundedView{ new_batch: true draw_bg.color: #1e1e2e <-- Batch 1 START
Label{text: "Title"} bg + text contained
Hr{}
View{ new_batch: true show_bg: true <-- Batch 2 START
draw_bg.color: #2a2a3d
Label{text: "Item 1"} bg + text contained
}
View{ new_batch: true show_bg: true <-- Batch 3 START
draw_bg.color: #2a2a3d
Label{text: "Item 2"} bg + text contained
}
}Each new_batch: true creates a DrawList2d internally. This means the GPU processes the draw commands in separate groups, ensuring correct layering within each batch.
Cost of new_batch
Every new_batch: true creates a new DrawList2d with its own command buffer. For small numbers (10-50), this is negligible. For hundreds of items, consider using PortalList (which handles batching internally through virtualization) rather than hundreds of individual new_batch Views.
PERFORMANCE IMPACT:
1-50 new_batch Views: Negligible overhead
50-200 new_batch Views: Slight draw call increase, still fine
200+ new_batch Views: Consider PortalList or texture_caching
1000+ new_batch Views: Definitely use PortalList---
2. GC Optimization Patterns
Pattern 1: Static App Shell
The most impactful GC optimization. Mark your application's structural UI (dock, tabs, toolbars) as static immediately after definition.
// Step 1: Define the full application structure
let AppDock = Dock{
tab_bar +: {
EditorTab := DockTab{
label := Label{text: "Editor" draw_text.color: #ddd}
}
SettingsTab := DockTab{
label := Label{text: "Settings" draw_text.color: #ddd}
}
}
root := DockSplitter{
axis: SplitterAxis.Horizontal
align: SplitterAlign.FromStart(300.0)
a: sidebar_tabs
b: main_tabs
}
sidebar_tabs := DockTabs{tabs: [file_tree]}
main_tabs := DockTabs{tabs: [editor settings]}
// Content templates
TabFileTree := TabFileTree{}
TabEditor := TabEditor{}
TabSettings := TabSettings{}
}
// Step 2: Mark entire tree as permanent
mod.gc.set_static(AppDock)
// Step 3: Clean up construction temporaries
mod.gc.run()
// Step 4: Start the app (dynamic content uses automatic GC)
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
body +: {
AppDock{}
}
}
}
}Why this matters: The set_static() call recursively walks the AppDock object graph and marks every reachable object, array, string, pod, handle, and regex as static. During subsequent GC cycles, these objects are skipped entirely in the mark phase, making GC much faster.
Pattern 2: GC Monitoring for Development
During development, use run_status() to understand your app's memory behavior:
// Add a debug button to trigger GC with stats
debug_gc_button := Button{text: "Run GC"}
// In Rust event handler:
// if self.ui.button(cx, ids!(debug_gc_button)).clicked(actions) {
// script_eval!(cx, {
// mod.gc.run_status()
// });
// }The output tells you:
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]
Interpreting the numbers:
S (Static) high = good, set_static is working
A (Alive) high = many live objects, normal for large UIs
R (Removed) high = lots of churn, maybe optimize allocation patterns
Time > 1ms = large heap, consider more set_static usagePattern 3: Bulk Operations with GC Control
When performing bulk operations that create many temporary objects, bracket them with GC:
fn import_large_dataset(data) {
// Disable automatic GC during bulk import by doing it manually after
for i in 0..data.len() {
items.push({
id: data[i].id
name: data[i].name
value: data[i].value
})
}
// Single GC pass after all allocations
mod.gc.run()
// Render once
ui.item_list.render()
}GC Heap Type Buckets
The GC tracks six independent heap categories. Each has its own free list and threshold:
HEAP CATEGORY CONTENTS MIN THRESHOLD
============= ======== =============
Objects Widget instances, closures, maps 1,024
Arrays Value arrays, typed arrays 128
Strings Interned text strings 256
Pods vec2, vec3, vec4, Inset, etc. 128
Handles Native Rust object wrappers 64
Regexes Compiled regex patterns 64
GC triggers when ANY category exceeds: MIN AND >= 2x since last GC---
3. Render Tree Optimization Strategies
Strategy 1: Minimal Render Scope
Always render the smallest possible sub-tree. A common mistake is rendering the root view when only a small part changed:
// BAD: Re-renders entire UI for a counter increment
fn increment() {
state.counter += 1
ui.main_view.render() // main_view contains header, sidebar, list, counter
}
// GOOD: Re-render only the counter display
fn increment() {
state.counter += 1
ui.counter_label.render() // Only the counter label rebuilds
}Strategy 2: Separate Static and Dynamic Content
Structure your widget tree so dynamic content lives in isolated sub-trees:
ui: Root{
main_window := Window{
body +: {
View{
flow: Down height: Fill
// STATIC: Header never changes, no on_render needed
RoundedView{
width: Fill height: 60
new_batch: true
draw_bg.color: #1a1a2e
padding: Inset{left: 16 right: 16}
align: Align{y: 0.5}
Label{text: "My App" draw_text.color: #fff draw_text.text_style.font_size: 18}
}
// DYNAMIC: List changes frequently
list_view := View{
width: Fill height: Fill
on_render: || {
for i, item in items {
ItemRow{label.text: item.name}
}
}
}
// DYNAMIC: Status bar updates occasionally
status_view := View{
width: Fill height: 30
on_render: || {
Label{text: status_text draw_text.color: #888}
}
}
}
}
}
}Now ui.list_view.render() and ui.status_view.render() can be called independently.
Strategy 3: Conditional Rendering
Skip expensive rendering when the data has not actually changed:
var last_rendered_count = 0
fn maybe_update_list() {
if items.len() != last_rendered_count {
last_rendered_count = items.len()
ui.list_view.render()
}
// No render call if count hasn't changed
}Strategy 4: Debounced Rendering for Rapid Input
For search-as-you-type or slider drag scenarios, avoid rendering on every keystroke:
// Rust side - use a timer to debounce
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if let Some(text) = self.ui.text_input(cx, ids!(search_input)).changed(actions) {
// Store the search text but don't render yet
self.pending_search = Some(text);
// Start/reset a 100ms timer
cx.start_timeout(0.1);
}
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Timer(_) = event {
if let Some(search) = self.pending_search.take() {
script_eval!(cx, {
state.search_query = search
ui.results_view.render()
});
}
}
}
}---
4. Memory Management Best Practices
Object Lifecycle
OBJECT LIFECYCLE IN SPLASH VM:
Allocation
|
v
[Live Object] ---.render()--> [Referenced by widget tree]
| |
| (no more references) | (.render() with new content)
v v
[Unreachable] <-------------- [Old content replaced]
|
| (GC mark phase: not reached)
v
[Swept / Freed]
|
v
[Free list] --> reused by next allocationAvoid Accidental Object Retention
Objects remain alive as long as any root can reach them. Common retention sources:
1. Global variables - var at module scope are permanent roots 2. Closures - Closures capture their enclosing scope 3. Static marking - mod.gc.set_static() is irreversible
// LEAK: Global array grows forever
var all_events = []
fn on_event(e) {
all_events.push(e) // Never shrinks!
}
// FIX: Bounded buffer
var recent_events = []
fn on_event(e) {
recent_events.push(e)
if recent_events.len() > 100 {
recent_events.splice(0, 1) // Remove oldest
}
}String Interning
Splash interns all strings. Creating the same string value twice returns the same ScriptString reference. This means:
- String comparison is fast (pointer equality)
- Duplicate strings don't consume extra memory
- But strings are only freed by GC, not eagerly
Handle Management
ScriptHandle wraps native Rust objects. They are reference-counted separately from the GC. Key points:
- Handles have a
ScriptHandleRefthat participates in Rust's reference counting - The GC marks handles reachable from the script heap
- Unreachable handles are freed during sweep, which triggers their Rust
Drop
---
5. Network Performance (HTTP Streaming)
Efficient Streaming Pattern
For streaming API responses (e.g., LLM chat), render incrementally:
var stream_buffer = ""
fn start_chat_stream(prompt) {
let req = net.HttpRequest{
url: "https://api.example.com/chat"
method: net.HttpMethod.POST
is_streaming: true
headers: {
"Content-Type": "application/json"
"Authorization": "Bearer " + api_key
}
body: {
messages: [{role: "user" content: prompt}]
stream: true
}.to_json()
}
stream_buffer = ""
net.http_request(req) do net.HttpEvents{
on_stream: |res| {
stream_buffer += res.body.to_string()
// Render the response view to show incremental updates
ui.response_view.render()
}
on_complete: |res| {
// Final render with complete content
ui.response_view.render()
}
on_error: |e| {
stream_buffer = "Error: request failed"
ui.response_view.render()
}
}
}Avoid Re-parsing on Every Chunk
When streaming JSON lines (e.g., SSE), accumulate and parse efficiently:
var partial_line = ""
var parsed_messages = []
fn handle_stream_chunk(chunk_text) {
partial_line += chunk_text
let lines = partial_line.split("\n")
// Process all complete lines
for i in 0..lines.len() - 1 {
let line = lines[i]
if line.len() > 0 {
let parsed = line.parse_json()
if parsed != nil {
parsed_messages.push(parsed)
}
}
}
// Keep the last (potentially incomplete) line
partial_line = lines[lines.len() - 1]
// Single render for all processed messages
ui.messages_view.render()
}---
6. Complete Before/After Optimization Examples
Example 1: Todo List (Batching Fix)
Before (broken - text invisible):
let TodoItem = RoundedView{
width: Fill height: Fit
padding: 12
draw_bg.color: #2a2a3d
draw_bg.border_radius: 6.0
label := Label{text: "task" draw_text.color: #ddd}
}
View{
flow: Down height: Fit spacing: 4 padding: 16
TodoItem{label.text: "Buy groceries"}
TodoItem{label.text: "Fix bug"}
TodoItem{label.text: "Write tests"}
}After (fixed - text visible):
let TodoItem = RoundedView{
width: Fill height: Fit
padding: 12
new_batch: true
draw_bg.color: #2a2a3d
draw_bg.border_radius: 6.0
label := Label{text: "task" draw_text.color: #ddd}
}
RoundedView{
flow: Down height: Fit spacing: 4 padding: 16
new_batch: true
draw_bg.color: #1e1e2e
draw_bg.border_radius: 10.0
Label{text: "My Tasks" draw_text.color: #fff draw_text.text_style.font_size: 14}
Hr{}
TodoItem{label.text: "Buy groceries"}
TodoItem{label.text: "Fix bug"}
TodoItem{label.text: "Write tests"}
}Changes:
- Added
new_batch: truetoTodoItemtemplate - Added
new_batch: trueto the outer container - Both container and items now render text on top of their backgrounds
Example 2: Large App with GC Optimization
Before (GC runs frequently, pauses noticeable):
use mod.prelude.widgets.*
// All templates defined at module level
let TabEditor = View{...}
let TabFileTree = View{...}
let TabSettings = View{...}
let TabSearch = View{...}
let AppDock = Dock{
// ... 20+ tab templates ...
}
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
body +: { AppDock{} }
}
}
}
// GC keeps scanning the entire AppDock tree every cycleAfter (GC skips static tree, runs faster):
use mod.prelude.widgets.*
let TabEditor = View{...}
let TabFileTree = View{...}
let TabSettings = View{...}
let TabSearch = View{...}
let AppDock = Dock{
// ... 20+ tab templates ...
}
// Mark the structural UI as permanent
mod.gc.set_static(AppDock)
mod.gc.run()
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
body +: { AppDock{} }
}
}
}
// GC now skips all static objects during mark phaseChanges:
- Added
mod.gc.set_static(AppDock)after tree definition - Added
mod.gc.run()to clean up construction temporaries - GC mark phase now skips the entire AppDock graph (objects, strings, arrays, pods, handles)
Example 3: Render Optimization for Dynamic Content
Before (re-renders entire UI on every change):
let state = {
items: []
selected: -1
filter_text: ""
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup: || { ui.main.render() }
main_window := Window{
body +: {
main := View{
flow: Down height: Fill
on_render: || {
// Header
RoundedView{
width: Fill height: 60
new_batch: true
draw_bg.color: #1a1a2e
Label{text: "Items: " + state.items.len() draw_text.color: #fff}
}
// Filter input
search := TextInput{text: state.filter_text}
// Item list
for i, item in state.items {
if item.name.contains(state.filter_text) {
ItemRow{...}
}
}
// Status bar
Label{text: "Selected: " + state.selected draw_text.color: #888}
}
}
}
}
}
}After (targeted renders for each section):
let state = {
items: []
selected: -1
filter_text: ""
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup: || {
ui.header_view.render()
ui.list_view.render()
ui.status_view.render()
}
main_window := Window{
body +: {
View{
flow: Down height: Fill
// Header - only re-renders when item count changes
header_view := View{
width: Fill height: 60
on_render: || {
RoundedView{
width: Fill height: Fill
new_batch: true
draw_bg.color: #1a1a2e
align: Align{y: 0.5}
padding: Inset{left: 16}
Label{text: "Items: " + state.items.len() draw_text.color: #fff}
}
}
}
// Search input (static, does not need on_render)
search := TextInput{text: ""}
// List - re-renders on filter or data change
list_view := View{
width: Fill height: Fill
on_render: || {
for i, item in state.items {
if item.name.contains(state.filter_text) {
ItemRow{...}
}
}
}
}
// Status - re-renders on selection change
status_view := View{
width: Fill height: 30
on_render: || {
Label{text: "Selected: " + state.selected draw_text.color: #888}
}
}
}
}
}
}
}
// In event handlers:
// On item added: ui.header_view.render() + ui.list_view.render()
// On filter changed: ui.list_view.render()
// On selection changed: ui.status_view.render()Changes:
- Split monolithic
on_renderinto three independent render zones - Each zone can be re-rendered independently
- Adding an item only re-renders header + list (not status)
- Changing selection only re-renders status (not header or list)
- Typing in filter only re-renders list (not header or status)
Example 4: PortalList Migration
Before (renders all 1000 items every frame):
list_view := View{
flow: Down height: Fill
on_render: || {
ScrollYView{
flow: Down spacing: 2
for i, item in all_items {
RoundedView{
width: Fill height: Fit
new_batch: true
padding: 8
draw_bg.color: #2a2a3d
draw_bg.border_radius: 4.0
label := Label{text: item.name draw_text.color: #ddd}
}
}
}
}
}After (only visible items rendered):
// Use PortalList with Rust-side Widget for virtualized rendering
// In Splash:
let MyList = #(MyList::register_widget(vm)){
list := PortalList{
width: Fill height: Fill
flow: Down spacing: 2
scroll_bar: ScrollBar{}
Item := RoundedView{
width: Fill height: Fit
new_batch: true
padding: 8
draw_bg.color: #2a2a3d
draw_bg.border_radius: 4.0
label := Label{text: "" draw_text.color: #ddd}
}
}
}// In Rust:
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 widget = list.item(cx, item_id, id!(Item));
widget.label(ids!(label)).set_text(cx, &self.items[item_id].name);
widget.draw_all(cx, &mut Scope::empty());
}
}
}
DrawStep::done()
}
}Changes:
- Replaced manual
forloop withPortalListvirtualization - Only ~20-30 visible items are drawn per frame instead of 1000
- Scroll performance is constant regardless of list size
- Memory usage bounded by visible item count, not total count