List Rendering Implementation¶
Overview¶
This document describes the implementation of list rendering in the nojs Go + WASM framework using the {@for} directive. This feature allows you to render lists of items from slices or arrays with optimal performance through mandatory key tracking.
Template Syntax¶
The list rendering syntax requires a trackBy clause to uniquely identify each item. The framework supports two trackBy formats:
1. Bare Variable (for primitive types like string, int, bool, uint64, etc.):
<ul>
{@for i, id := range IDs trackBy id}
<li>Item</li>
{@endfor}
</ul>
2. Dot-Notation (for struct fields):
<ul>
{@for i, user := range Users trackBy user.ID}
<li>User item</li>
{@endfor}
</ul>
Supported Syntax Variations¶
Struct Field with Index and Value¶
{@for i, user := range Users trackBy user.ID}
<li>Item {i}: {user.Name}</li>
{@endfor}
Struct Field Using Underscore to Ignore Index¶
{@for _, user := range Users trackBy user.ID}
<div>User: {user.Name}</div>
{@endfor}
Primitive Type with Index¶
{@for i, tag := range Tags trackBy tag}
<div>Tag {i}: {tag}</div>
{@endfor}
Primitive Type Without Index¶
{@for _, id := range IDs trackBy id}
<span>ID: {id}</span>
{@endfor}
Important: You must explicitly include both the index and value variables in the {@for} directive, following Go's standard for...range syntax. Use _ (underscore) to ignore the index if you don't need it.
Invalid Syntax - Will Cause Compilation Error:
<!-- ❌ INVALID - Missing index variable -->
{@for user := range Users trackBy user.ID}
<li>User item</li>
{@endfor}
Error message you'll see:
template syntax error: Invalid {@for} syntax at line(s): [10]
The {@for} directive requires both index and value variables.
Correct syntax: {@for index, value := range Slice trackBy value.Field}
To ignore the index, use underscore: {@for _, value := range Slice trackBy value.Field}
Example: {@for _, user := range Users trackBy user.ID}
Required Components¶
{@for}- Opens a for-loop block- Index variable - REQUIRED - Loop index or
_to ignore - Value variable - REQUIRED - The loop item variable name
rangeexpression - Must reference an exported slice/array field on the componenttrackByclause - REQUIRED - Expression that resolves to a unique identifier for each item{@endfor}- Closes the for-loop block
Why trackBy is Mandatory¶
Unlike many frameworks where keys are optional, the nojs framework requires the trackBy clause for several reasons:
- Prevents Common Bugs: Forces developers to think about item identity upfront
- Enables Future Optimization: Sets foundation for efficient VDOM diffing/reconciliation
- Type Safety: Validates at compile time that the trackBy expression is valid
- Best Practice Enforcement: Eliminates the "missing key" footgun from day one
How It Works¶
1. Compile-Time Validation¶
The compiler performs the following checks:
- Directive Matching: Validates that every
{@for}has a corresponding{@endfor} - Field Existence: Verifies the range expression references an exported field
- TrackBy Requirement: Ensures the trackBy clause is present and valid
- Syntax Validation: Checks proper Go range syntax
Example validation error for missing {@endfor}:
template validation error in UserList.gt.html:
found 1 {@for} directive(s) but only 0 {@endfor} directive(s).
{@for} found at line(s): [10]
{@endfor} found at line(s): []
Missing 1 {@endfor} directive(s).
Example error for missing field:
Compilation Error in UserList.gt.html: Field 'Users' not found on component 'UserList'.
Available fields: [Title]
2. Preprocessing¶
Before HTML parsing, the preprocessFor() function transforms directives into placeholder HTML elements:
Input:
{@for i, user := range Users trackBy user.ID}
<li>User item</li>
{@endfor}
Output:
<go-for data-index="i" data-value="user" data-range="Users" data-trackby="user.ID">
<li>User item</li>
</go-for>
3. Code Generation¶
The generateForLoopCode() function generates Go code that:
- Creates a slice to collect VNodes
- Iterates using Go's for...range
- Stores the trackBy key for future optimization
- Optionally warns about empty slices (with -dev-warnings flag)
Generated Code:
func() []*vdom.VNode {
var user_nodes []*vdom.VNode
// Development warning for empty slice (only if -dev-warnings flag is set)
if len(c.Users) == 0 {
console.Warn("[@for] Rendering empty list for 'Users' in UserList. Consider using {@if} to handle empty state.")
}
for i, user := range c.Users {
user_key := user.ID
_ = user_key // trackBy key stored for future diff optimization
user_child := vdom.NewVNode("li", nil, nil, "User item")
if user_child != nil {
user_nodes = append(user_nodes, user_child)
}
}
return user_nodes
}()
4. Empty Slice Handling¶
When a slice is nil or empty:
- Go's for...range executes zero iterations (safe, no panic)
- The loop renders nothing (empty VNode slice)
- With -dev-warnings, a console warning is logged
- Parent element renders with no children
Example: Empty <ul> renders as <ul></ul> (no <li> elements)
Development Warnings¶
The -dev-warnings Flag¶
Enable development warnings during compilation:
cd compiler
go run . -in .. -dev
What it does:
- Adds console.Warn() calls when rendering empty slices
- Suggests using {@if} to handle empty states
- Zero performance impact in production (warnings not generated without flag)
Console Output (with warnings enabled):
⚠️ [@for] Rendering empty list for 'Users' in UserList. Consider using {@if} to handle empty state.
Production Build (without warnings):
cd compiler
go run . -in ..
No warning code is generated - cleaner output, smaller bundle.
Example Usage¶
Component Struct¶
package appcomponents
import "github.com/ForgeLogic/nojs/runtime"
type User struct {
ID int
Name string
}
type UserList struct {
runtime.ComponentBase
Users []User
Title string
}
func (u *UserList) OnInit() {
u.Users = []User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
{ID: 3, Name: "Charlie"},
}
}
func (u *UserList) AddUser() {
newID := len(u.Users) + 1
u.Users = append(u.Users, User{
ID: newID,
Name: "User " + string(rune('A' + newID - 1)),
})
u.StateHasChanged()
}
func (u *UserList) ClearUsers() {
u.Users = []User{}
u.StateHasChanged()
}
Template¶
<div>
<h2>{Title}</h2>
<div>
<button @onclick="AddUser">Add User</button>
<button @onclick="ClearUsers">Clear All</button>
</div>
<ul>
{@for i, user := range Users trackBy user.ID}
<li>User item</li>
{@endfor}
</ul>
</div>
Generated Code¶
func (c *UserList) Render(r *runtime.Renderer) *vdom.VNode {
return vdom.Div(nil,
vdom.NewVNode("h2", nil, nil, fmt.Sprintf("%v", c.Title)),
vdom.Div(nil,
vdom.Button("Add User", map[string]any{"onClick": c.AddUser}),
vdom.Button("Clear All", map[string]any{"onClick": c.ClearUsers})),
vdom.Div(nil, func() []*vdom.VNode {
var allChildren []*vdom.VNode
allChildren = append(allChildren, func() []*vdom.VNode {
var user_nodes []*vdom.VNode
// Development warning (only with -dev-warnings flag)
if len(c.Users) == 0 {
console.Warn("[@for] Rendering empty list for 'Users' in UserList. Consider using {@if} to handle empty state.")
}
for i, user := range c.Users {
user_key := user.ID
_ = user_key // trackBy key stored for future diff optimization
user_child := vdom.NewVNode("li", nil, nil, "User item")
if user_child != nil {
user_nodes = append(user_nodes, user_child)
}
}
return user_nodes
}()...)
return allChildren
}()...))
}
Handling Empty States¶
Recommended Pattern: Use {@if} Directive¶
<div>
{@if len(Users) == 0}
<p>No users found. Click "Add User" to get started.</p>
{@else}
<ul>
{@for _, user := range Users trackBy user.ID}
<li>User item</li>
{@endfor}
</ul>
{@endif}
</div>
Why this pattern? - Explicit control over empty state UI - No reliance on dev warnings for UX - Clean separation between "no data" and "has data" states - Better user experience
Implementation Details¶
File Modifications¶
compiler/main.go:
- Added -dev-warnings CLI flag
compiler/compiler.go:
- Added compileOptions struct to pass flags through compilation
- Added extractTypeName() function to handle complex types (slices, pointers)
- Added preprocessFor() function for directive preprocessing
- Added generateForLoopCode() function for code generation
- Updated generateNodeCode() to handle <go-for> placeholder nodes
- Updated child collection logic to spread for-loop VNode slices
vdom/vnode_core.go:
- Added Key interface{} field to VNode struct for future reconciliation
Regex Patterns¶
// With index: {@for i, user := range Users trackBy user.ID}
reFor := regexp.MustCompile(`\{\@for\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:=\s*range\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+trackBy\s+([a-zA-Z0-9_.]+)\}`)
// Without index: {@for user := range Users trackBy user.ID}
reForNoIndex := regexp.MustCompile(`\{\@for\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*:=\s*range\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+trackBy\s+([a-zA-Z0-9_.]+)\}`)
// End directive
reEndFor := regexp.MustCompile(`\{\@endfor\}`)
Placeholder HTML Elements¶
<go-for data-index="..." data-value="..." data-range="..." data-trackby="...">- For loop wrapper with metadata
Nil Slice Behavior¶
Q: What happens if the slice is nil?
A: Go's for...range over a nil slice executes zero iterations - no panic, no special handling needed.
var users []User // nil slice
for i, user := range users {
// This never executes
}
// Code continues normally
The generated code naturally handles nil slices:
- Loop body doesn't execute
- Empty VNode slice is returned
- Parent element renders with no children
- Optional dev warning logs to console
Current Limitations¶
Loop Variable Data Binding (Planned)¶
Currently, you cannot bind to loop variable fields in the template:
<!-- NOT YET SUPPORTED -->
{@for _, user := range Users trackBy user.ID}
<li>{user.Name} (ID: {user.ID})</li>
{@endfor}
Workaround: Use static content for now, or implement component for each item.
Future Enhancement: Compiler will need context tracking to distinguish component fields from loop variables.
Nested Loops (Supported)¶
You can nest {@for} loops:
{@for _, category := range Categories trackBy category.ID}
<div>
<h3>{category.Name}</h3>
<ul>
{@for _, item := range category.Items trackBy item.ID}
<li>Item</li>
{@endfor}
</ul>
</div>
{@endfor}
Future Enhancements¶
- Loop Variable Data Binding: Support
{user.Name}expressions inside loops - VDOM Reconciliation: Use stored keys for efficient list diffing
- Index-Based Keys Warning: Warn when using loop index as trackBy (anti-pattern)
- Complex TrackBy Expressions: Support composite keys like
user.Org + "-" + user.ID
Testing¶
To test list rendering:
- Create a component with a slice field
- Add
{@for}directive to template withtrackBy - Compile:
cd compiler && go run . -in .. -dev - Build WASM:
GOOS=js GOARCH=wasm go build -o main.wasm - Open
index.htmlin browser - Check browser console for dev warnings (if enabled)
- Test add/remove/clear operations
Troubleshooting¶
Error: "found X {@for} directive(s) but only Y {@endfor} directive(s)"
- Every {@for} must have a matching {@endfor}
- Check line numbers in error message
Error: "Field 'Users' not found on component" - Ensure field is exported (capitalized) - Check spelling matches exactly
Error: "Invalid {@for} directive - missing required attributes"
- Ensure trackBy clause is present
- Check syntax: {@for var := range Slice trackBy key}
Warning: Empty list rendering
- Add {@if len(Slice) > 0} to handle empty state
- Or disable warnings by removing -dev-warnings flag
List doesn't update after adding items
- Call StateHasChanged() after modifying the slice
- Ensure component method properly appends/removes items
Commit Message¶
feat(compiler): implement list rendering with mandatory trackBy and dev warnings
- Add {@for} directive for rendering slices/arrays
- Require trackBy clause for unique item identification
- Add -dev-warnings CLI flag for optional empty slice console warnings
- Support both index+value and value-only syntax
- Add VNode.Key field for future diff optimization
- Update type inspection to handle slice/array types
- Add comprehensive validation and error messages