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:
1 parent
b0e38db22c
commit
1575571f3e
6 files changed
+761
-474
No files matched your search
+531
-473
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
@@ -40,6 +40,13 @@ class SignalLLVMIRGenerator:
|
||||
# Track signal-to-conditional dependencies for reactivity
|
||||
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
|
||||
self.current_component = None
|
||||
|
||||
@@ -74,14 +81,20 @@ class SignalLLVMIRGenerator:
|
||||
self.conditionals = []
|
||||
self.conditional_map = {}
|
||||
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_conditionals(component)
|
||||
self._collect_reactive_attributes(component)
|
||||
|
||||
# Generate component-specific code
|
||||
self._emit_signal_globals(component)
|
||||
self._emit_conditional_globals()
|
||||
self._emit_reactive_attribute_globals()
|
||||
self._emit_helper_functions()
|
||||
self._emit_event_handlers()
|
||||
self._emit_conditional_functions()
|
||||
@@ -176,12 +189,76 @@ class SignalLLVMIRGenerator:
|
||||
signals.extend(self._extract_signals_from_expr(expr["left"]))
|
||||
for comparator in expr.get("comparators", []):
|
||||
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":
|
||||
# Constants don't reference signals
|
||||
pass
|
||||
|
||||
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:
|
||||
"""Emit module header."""
|
||||
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("")
|
||||
|
||||
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:
|
||||
"""Emit JS bridge function declarations."""
|
||||
self.lines.append("; JavaScript bridge function declarations")
|
||||
@@ -341,6 +430,11 @@ class SignalLLVMIRGenerator:
|
||||
if signal_name in self.signal_conditional_deps:
|
||||
for cond_id in self.signal_conditional_deps[signal_name]:
|
||||
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":
|
||||
# Handle signal updates (count += 1)
|
||||
signal_name = stmt["target"]
|
||||
@@ -363,6 +457,36 @@ class SignalLLVMIRGenerator:
|
||||
for cond_id in self.signal_conditional_deps[signal_name]:
|
||||
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:
|
||||
"""Emit init and reevaluate functions for each conditional."""
|
||||
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))"
|
||||
)
|
||||
|
||||
# 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
|
||||
self.parent_element_stack.append(elem_var)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
Reference in new issue
Block a user