Implement reactive attributes for minimal DOM updates

Features:
- Track attributes that depend on signals (ternary expressions)
- Store element references in globals for reactive attributes
- Re-evaluate and update only changed attributes when signals change
- Similar to reactive conditionals but for attributes

Performance:
- Minimal DOM updates - only changed attributes are updated
- No full re-renders, no virtual DOM diffing
- Direct attribute updates via js_set_attribute

Example:
  class_='tab active' if tab_index == 0 else 'tab'

When tab_index changes, only the class attribute updates.

Implementation:
- Collection phase: scan for attributes with signal dependencies
- Render phase: store element references to globals
- Signal update: re-evaluate expression and update attribute

All tests passing (55 passed, 2 skipped)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aaron SinclairandClaude committed 2025-11-15 11:23:09 -08:00
1 parent b0e38db22c
commit 1575571f3e
6 files changed
+761 -474

No files matched your search

+531 -473
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+131 -1
View File
@@ -40,6 +40,13 @@ class SignalLLVMIRGenerator:
# Track signal-to-conditional dependencies for reactivity # Track signal-to-conditional dependencies for reactivity
self.signal_conditional_deps: dict[str, list[int]] = {} # signal_name -> [conditional_ids] self.signal_conditional_deps: dict[str, list[int]] = {} # signal_name -> [conditional_ids]
# Track reactive attributes (attributes that depend on signals)
self.reactive_attributes: list[dict[str, Any]] = [] # List of reactive attribute metadata
self.reactive_attr_map: dict[int, dict[str, Any]] = {} # attr_id -> attr_info
self.next_reactive_attr_id = 0
self.signal_attribute_deps: dict[str, list[int]] = {} # signal_name -> [attr_ids]
self.stmt_reactive_attrs: dict[int, list[int]] = {} # stmt id() -> [attr_ids]
# Track current component for signal scoping # Track current component for signal scoping
self.current_component = None self.current_component = None
@@ -74,14 +81,20 @@ class SignalLLVMIRGenerator:
self.conditionals = [] self.conditionals = []
self.conditional_map = {} self.conditional_map = {}
self.signal_conditional_deps = {} self.signal_conditional_deps = {}
self.reactive_attributes = []
self.reactive_attr_map = {}
self.signal_attribute_deps = {}
self.stmt_reactive_attrs = {}
# Collect signals, handlers, and conditionals for this component # Collect signals, handlers, conditionals, and reactive attributes for this component
self._collect_signals_and_handlers(component) self._collect_signals_and_handlers(component)
self._collect_conditionals(component) self._collect_conditionals(component)
self._collect_reactive_attributes(component)
# Generate component-specific code # Generate component-specific code
self._emit_signal_globals(component) self._emit_signal_globals(component)
self._emit_conditional_globals() self._emit_conditional_globals()
self._emit_reactive_attribute_globals()
self._emit_helper_functions() self._emit_helper_functions()
self._emit_event_handlers() self._emit_event_handlers()
self._emit_conditional_functions() self._emit_conditional_functions()
@@ -176,12 +189,76 @@ class SignalLLVMIRGenerator:
signals.extend(self._extract_signals_from_expr(expr["left"])) signals.extend(self._extract_signals_from_expr(expr["left"]))
for comparator in expr.get("comparators", []): for comparator in expr.get("comparators", []):
signals.extend(self._extract_signals_from_expr(comparator)) signals.extend(self._extract_signals_from_expr(comparator))
elif expr["type"] == "ifexp":
# Ternary expression - check test, body, and orelse
signals.extend(self._extract_signals_from_expr(expr["test"]))
signals.extend(self._extract_signals_from_expr(expr["body"]))
signals.extend(self._extract_signals_from_expr(expr["orelse"]))
elif expr["type"] == "constant": elif expr["type"] == "constant":
# Constants don't reference signals # Constants don't reference signals
pass pass
return signals return signals
def _collect_reactive_attributes(self, component: dict[str, Any]) -> None:
"""Scan IR tree for attributes that depend on signals."""
def scan_statements(stmts: list[dict[str, Any]]):
for stmt in stmts:
if stmt["type"] == "with":
# Check if any props have signal dependencies
element = stmt["element"]
props = element.get("props", {})
for prop_name, prop_value in props.items():
# Skip event handlers
if prop_name == "onclick":
continue
# Check if this prop depends on signals
signals = self._extract_signals_from_expr(prop_value)
if signals:
# This attribute is reactive!
attr_id = self.next_reactive_attr_id
self.next_reactive_attr_id += 1
attr_name = prop_name.rstrip("_") # Remove trailing underscore
stmt_id = id(stmt)
attr_info = {
"id": attr_id,
"stmt_id": stmt_id, # Use Python id() to identify statement
"attr_name": attr_name,
"expression": prop_value,
"signals": signals,
}
self.reactive_attributes.append(attr_info)
self.reactive_attr_map[attr_id] = attr_info
# Build stmt -> attr map for lookup during render
if stmt_id not in self.stmt_reactive_attrs:
self.stmt_reactive_attrs[stmt_id] = []
self.stmt_reactive_attrs[stmt_id].append(attr_id)
# Register this attribute as dependent on these signals
for signal_name in signals:
if signal_name not in self.signal_attribute_deps:
self.signal_attribute_deps[signal_name] = []
self.signal_attribute_deps[signal_name].append(attr_id)
# Recursively scan nested statements
scan_statements(stmt.get("body", []))
elif stmt["type"] == "if":
scan_statements(stmt["body"])
scan_statements(stmt.get("orelse", []))
elif stmt["type"] == "match":
for case in stmt["cases"]:
scan_statements(case.get("body", []))
scan_statements(component["body"])
def _emit_header(self) -> None: def _emit_header(self) -> None:
"""Emit module header.""" """Emit module header."""
self.lines.append("; Generated by Ritz Framework with Signals") self.lines.append("; Generated by Ritz Framework with Signals")
@@ -227,6 +304,18 @@ class SignalLLVMIRGenerator:
self.lines.append(f"@cond_{cond_id}_case_{case_idx}_root = global i32 0") self.lines.append(f"@cond_{cond_id}_case_{case_idx}_root = global i32 0")
self.lines.append("") self.lines.append("")
def _emit_reactive_attribute_globals(self) -> None:
"""Emit global storage for reactive attribute element references."""
if not self.reactive_attributes:
return
self.lines.append("; Reactive attribute element storage")
for attr_info in self.reactive_attributes:
attr_id = attr_info["id"]
# Store element reference so we can update its attribute later
self.lines.append(f"@reactive_attr_{attr_id}_elem = global i32 0")
self.lines.append("")
def _emit_function_declarations(self) -> None: def _emit_function_declarations(self) -> None:
"""Emit JS bridge function declarations.""" """Emit JS bridge function declarations."""
self.lines.append("; JavaScript bridge function declarations") self.lines.append("; JavaScript bridge function declarations")
@@ -341,6 +430,11 @@ class SignalLLVMIRGenerator:
if signal_name in self.signal_conditional_deps: if signal_name in self.signal_conditional_deps:
for cond_id in self.signal_conditional_deps[signal_name]: for cond_id in self.signal_conditional_deps[signal_name]:
self.lines.append(f" call void @reevaluate_conditional_{cond_id}()") self.lines.append(f" call void @reevaluate_conditional_{cond_id}()")
# Trigger reactive updates for attributes that depend on this signal
if signal_name in self.signal_attribute_deps:
for attr_id in self.signal_attribute_deps[signal_name]:
self._emit_update_reactive_attribute(attr_id)
elif stmt["type"] == "augassign": elif stmt["type"] == "augassign":
# Handle signal updates (count += 1) # Handle signal updates (count += 1)
signal_name = stmt["target"] signal_name = stmt["target"]
@@ -363,6 +457,36 @@ class SignalLLVMIRGenerator:
for cond_id in self.signal_conditional_deps[signal_name]: for cond_id in self.signal_conditional_deps[signal_name]:
self.lines.append(f" call void @reevaluate_conditional_{cond_id}()") self.lines.append(f" call void @reevaluate_conditional_{cond_id}()")
# Trigger reactive updates for attributes that depend on this signal
if signal_name in self.signal_attribute_deps:
for attr_id in self.signal_attribute_deps[signal_name]:
self._emit_update_reactive_attribute(attr_id)
def _emit_update_reactive_attribute(self, attr_id: int) -> None:
"""Emit inline code to update a reactive attribute."""
# Look up attribute by ID
attr_info = self.reactive_attr_map.get(attr_id)
if not attr_info:
return # Attribute not found
attr_name = attr_info["attr_name"]
expression = attr_info["expression"]
# Load the element reference
elem_var = self._new_var()
self.lines.append(f" %{elem_var} = load i32, i32* @reactive_attr_{attr_id}_elem")
# Re-evaluate the attribute expression
if expression["type"] == "ifexp":
value_ptr = self._emit_string_ifexp(expression)
# Set the attribute
name_str = self._get_or_create_string(attr_name)
self.lines.append(
f" call void @js_set_attribute(i32 %{elem_var}, "
f"i8* getelementptr inbounds ({self._string_type(attr_name)}, {self._string_type(attr_name)}* {name_str}, i32 0, i32 0), "
f"i8* %{value_ptr})"
)
# Could add support for other expression types here
def _emit_conditional_functions(self) -> None: def _emit_conditional_functions(self) -> None:
"""Emit init and reevaluate functions for each conditional.""" """Emit init and reevaluate functions for each conditional."""
for cond_info in self.conditionals: for cond_info in self.conditionals:
@@ -1021,6 +1145,12 @@ class SignalLLVMIRGenerator:
f"i8* getelementptr inbounds ({self._string_type(onclick_handler)}, {self._string_type(onclick_handler)}* {handler_str}, i32 0, i32 0))" f"i8* getelementptr inbounds ({self._string_type(onclick_handler)}, {self._string_type(onclick_handler)}* {handler_str}, i32 0, i32 0))"
) )
# Store element reference if this element has reactive attributes
stmt_id = id(stmt)
if stmt_id in self.stmt_reactive_attrs:
for attr_id in self.stmt_reactive_attrs[stmt_id]:
self.lines.append(f" store i32 %{elem_var}, i32* @reactive_attr_{attr_id}_elem")
# Push this element as the parent for children # Push this element as the parent for children
self.parent_element_stack.append(elem_var) self.parent_element_stack.append(elem_var)
+99
View File
@@ -0,0 +1,99 @@
"""Tests for reactive attributes that update when signals change."""
import pytest
from ritz.parser import parse_component
from ritz.llvm_codegen_signals import generate_llvm_ir
def test_parse_reactive_attribute():
"""Test parsing attribute with signal dependency."""
source = """
from ritz.syntax import *
@component
def Toggle():
active = signal(1)
with Button(class_='active' if active else 'inactive'):
yield "Click"
"""
ir = parse_component(source)
# Should have ternary expression in class attribute
with_stmt = [s for s in ir["body"] if s["type"] == "with"][0]
class_prop = with_stmt["element"]["props"]["class_"]
assert class_prop["type"] == "ifexp"
assert class_prop["test"]["id"] == "active"
def test_reactive_attribute_infrastructure():
"""Test that reactive attribute tracking is generated."""
source = """
from ritz.syntax import *
@component
def Toggle():
active = signal(1)
def toggle():
active = 0 if active else 1
with Button(class_='btn-active' if active else 'btn-inactive', onclick=toggle):
yield "Toggle"
"""
ir = parse_component(source)
llvm_ir = generate_llvm_ir(ir)
# Should have storage for the button element
assert "@reactive_attr_" in llvm_ir or "@elem_" in llvm_ir
# Should update attribute when signal changes in toggle()
# The toggle function should re-evaluate the class expression and update
assert "js_set_attribute" in llvm_ir
def test_multiple_reactive_attributes():
"""Test multiple elements with reactive attributes."""
source = """
from ritz.syntax import *
@component
def Tabs():
tab = signal(0)
def set_tab(value):
tab = value
with Ul():
with Li(class_='active' if tab == 0 else ''):
yield "Tab 0"
with Li(class_='active' if tab == 1 else ''):
yield "Tab 1"
"""
ir = parse_component(source)
llvm_ir = generate_llvm_ir(ir)
# Both Li elements should have reactive attributes
# When tab signal changes, both should update
assert llvm_ir.count("js_set_attribute") >= 2
def test_reactive_attribute_with_comparison():
"""Test reactive attribute with comparison in ternary."""
source = """
from ritz.syntax import *
@component
def Counter():
count = signal(5)
with Div(class_='high' if count > 10 else 'low'):
yield "Value"
"""
ir = parse_component(source)
llvm_ir = generate_llvm_ir(ir)
# Should emit comparison when evaluating class
assert "icmp" in llvm_ir
assert "select" in llvm_ir