**Reactive Text Nodes:**
- F-strings with signal dependencies now update automatically
- Added js_create_text_with_int() and js_update_text_with_int() for efficient "prefix{int}" pattern
- Text nodes stored in @reactive_text_N_node globals for updates
- Scan and track f-string dependencies during collection phase
**Binary Operations:**
- Implemented BinOp support in parser and codegen
- Maps Add → add i32, Sub → sub i32, Mult → mul i32, Div → sdiv i32, Mod → srem i32
- count = count + 1 now generates correct arithmetic instead of store i32 0
**LLVM Syntax Fixes (clang 20 compatibility):**
- Fixed getelementptr syntax: removed parentheses around type
- Before: getelementptr inbounds ([N x i8], [N x i8]* @str, ...)
- After: getelementptr inbounds [N x i8], [N x i8]* @str, ...
- Created _emit_string_ptr() helper for consistent string pointer generation
- Fixed in: element creation, attribute setting, event binding, select instructions
**Runtime (ritz-runtime.js):**
- Added js_create_text_with_int(prefix, intValue) - creates text node with "prefix" + intValue
- Added js_update_text_with_int(node, prefix, intValue) - updates text content
**Result:**
✅ TextInput counter increments/decrements correctly
✅ Tabs example works (reactive attributes, conditionals)
✅ Both examples compile with clang 20
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
/**
|
|
* Ritz Runtime - WebAssembly DOM Bridge
|
|
*
|
|
* Provides the runtime environment for Ritz components compiled to WASM.
|
|
*/
|
|
|
|
class RitzRuntime {
|
|
constructor(rootElement) {
|
|
this.rootElement = rootElement || document.getElementById('app');
|
|
this.nodes = new Map();
|
|
this.nextNodeId = 1;
|
|
this.log = [];
|
|
this.wasmInstance = null;
|
|
this.debugEnabled = true;
|
|
}
|
|
|
|
// ===== DOM Manipulation Bridge =====
|
|
|
|
js_create_element(tagPtr) {
|
|
const tag = this.readString(tagPtr);
|
|
const element = document.createElement(tag);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, element);
|
|
this._log(`createElement("${tag}") = ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_create_text(textPtr) {
|
|
const text = this.readString(textPtr);
|
|
const textNode = document.createTextNode(text);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, textNode);
|
|
this._log(`createTextNode("${text}") = ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_append_child(parentId, childId) {
|
|
const parent = this.nodes.get(parentId);
|
|
const child = this.nodes.get(childId);
|
|
if (parent && child) {
|
|
parent.appendChild(child);
|
|
this._log(`appendChild(${parentId}, ${childId})`);
|
|
}
|
|
}
|
|
|
|
js_set_attribute(elemId, namePtr, valuePtr) {
|
|
const element = this.nodes.get(elemId);
|
|
const name = this.readString(namePtr);
|
|
const value = this.readString(valuePtr);
|
|
if (element) {
|
|
if (name === 'class') {
|
|
element.className = value;
|
|
} else {
|
|
element.setAttribute(name, value);
|
|
}
|
|
this._log(`setAttribute(${elemId}, "${name}", "${value}")`);
|
|
}
|
|
}
|
|
|
|
js_bind_event(elemId, eventNamePtr, handlerNamePtr) {
|
|
const element = this.nodes.get(elemId);
|
|
const eventName = this.readString(eventNamePtr);
|
|
const handlerName = this.readString(handlerNamePtr);
|
|
|
|
if (element && this.wasmInstance) {
|
|
const handler = this.wasmInstance.exports[handlerName];
|
|
if (handler) {
|
|
element.addEventListener(eventName, () => {
|
|
this._log(`[Event] ${eventName} → ${handlerName}()`);
|
|
handler();
|
|
this.updateSignals();
|
|
});
|
|
this._log(`bindEvent(${elemId}, "${eventName}", "${handlerName}")`);
|
|
} else {
|
|
console.error(`Handler ${handlerName} not found in WASM exports`);
|
|
}
|
|
}
|
|
}
|
|
|
|
js_set_root(elemId) {
|
|
const element = this.nodes.get(elemId);
|
|
if (element) {
|
|
this.rootElement.innerHTML = '';
|
|
this.rootElement.appendChild(element);
|
|
this._log(`setRoot(${elemId})`);
|
|
}
|
|
}
|
|
|
|
js_update_text_content(nodeId, textPtr) {
|
|
const node = this.nodes.get(nodeId);
|
|
const text = this.readString(textPtr);
|
|
if (node) {
|
|
node.textContent = text;
|
|
this._log(`updateTextContent(${nodeId}, "${text}")`);
|
|
}
|
|
}
|
|
|
|
js_create_text_with_int(prefixPtr, intValue) {
|
|
const prefix = this.readString(prefixPtr);
|
|
const text = prefix + intValue.toString();
|
|
const textNode = document.createTextNode(text);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, textNode);
|
|
this._log(`createTextWithInt("${prefix}", ${intValue}) = "${text}" (id=${id})`);
|
|
return id;
|
|
}
|
|
|
|
js_update_text_with_int(nodeId, prefixPtr, intValue) {
|
|
const node = this.nodes.get(nodeId);
|
|
const prefix = this.readString(prefixPtr);
|
|
const text = prefix + intValue.toString();
|
|
if (node) {
|
|
node.textContent = text;
|
|
this._log(`updateTextWithInt(${nodeId}, "${prefix}", ${intValue}) = "${text}"`);
|
|
}
|
|
}
|
|
|
|
// ===== Signal Management =====
|
|
|
|
updateSignals() {
|
|
// Discover and update all signals
|
|
if (!this.wasmInstance) return;
|
|
|
|
const exports = this.wasmInstance.exports;
|
|
|
|
// Find all get_* functions (signal getters)
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('get_') && typeof fn === 'function') {
|
|
const signalName = name.substring(4); // Remove 'get_' prefix
|
|
const value = fn();
|
|
this._log(`[Signal] ${signalName} = ${value}`);
|
|
|
|
// Update DOM elements bound to this signal
|
|
// TODO: Track signal-to-DOM bindings during render for efficiency
|
|
this.updateSignalDisplay(signalName, value);
|
|
}
|
|
}
|
|
|
|
// Re-evaluate all reactive conditionals
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('reevaluate_conditional_') && typeof fn === 'function') {
|
|
fn();
|
|
this._log(`[Reactive] ${name}()`);
|
|
}
|
|
}
|
|
|
|
// Re-evaluate all reactive match statements
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('reevaluate_match_') && typeof fn === 'function') {
|
|
fn();
|
|
this._log(`[Reactive] ${name}()`);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateSignalDisplay(signalName, value) {
|
|
// Find DOM elements that should display this signal
|
|
// For now, update any element with class matching signal name or data-signal attribute
|
|
const selectors = [
|
|
`[data-signal="${signalName}"]`,
|
|
`.${signalName}`,
|
|
'.count', // Hardcoded for demo - TODO: track bindings properly
|
|
];
|
|
|
|
selectors.forEach(selector => {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach(el => {
|
|
// Check if text contains the signal name or looks like a counter
|
|
const text = el.textContent;
|
|
if (text.includes(signalName) || text.match(/\d+/)) {
|
|
// Replace the number in the text
|
|
el.textContent = text.replace(/\d+/, value);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ===== Reactive Conditional Rendering =====
|
|
|
|
js_create_marker() {
|
|
// Create a comment node as a marker for conditional insertion
|
|
const marker = document.createComment('conditional-marker');
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, marker);
|
|
this._log(`Created marker node ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_replace_at_marker(markerId, oldNodeId, newNodeId) {
|
|
// Replace DOM node at marker position
|
|
const marker = this.nodes.get(markerId);
|
|
if (!marker) {
|
|
console.error(`[replace_at_marker] Marker ${markerId} not found`);
|
|
return;
|
|
}
|
|
|
|
const parent = marker.parentNode;
|
|
if (!parent) {
|
|
console.error(`[replace_at_marker] Marker ${markerId} has no parent`);
|
|
return;
|
|
}
|
|
|
|
this._log(`[replace_at_marker] marker=${markerId}, old=${oldNodeId}, new=${newNodeId}`);
|
|
|
|
// Remove old node if it exists (0 means no old node to remove)
|
|
if (oldNodeId !== 0) {
|
|
const oldNode = this.nodes.get(oldNodeId);
|
|
if (!oldNode) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} not found in registry`);
|
|
} else if (!oldNode.parentNode) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} not in DOM`);
|
|
} else if (oldNode.parentNode !== parent) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} in wrong parent`);
|
|
oldNode.parentNode.removeChild(oldNode);
|
|
this._log(`[replace_at_marker] Removed ${oldNodeId} from wrong parent`);
|
|
} else {
|
|
// Remove from correct parent
|
|
parent.removeChild(oldNode);
|
|
this._log(`[replace_at_marker] Removed ${oldNodeId} from correct parent`);
|
|
}
|
|
}
|
|
|
|
// Insert new node after marker (only if newNodeId is not 0)
|
|
if (newNodeId !== 0) {
|
|
const newNode = this.nodes.get(newNodeId);
|
|
if (!newNode) {
|
|
console.error(`[replace_at_marker] New node ${newNodeId} not found`);
|
|
return;
|
|
}
|
|
|
|
// Insert right after the marker
|
|
// If newNode is already in DOM elsewhere, this will move it
|
|
const nextSibling = marker.nextSibling;
|
|
if (nextSibling) {
|
|
parent.insertBefore(newNode, nextSibling);
|
|
this._log(`[replace_at_marker] Inserted ${newNodeId} before sibling`);
|
|
} else {
|
|
parent.appendChild(newNode);
|
|
this._log(`[replace_at_marker] Appended ${newNodeId} (no sibling)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Memory & String Utilities =====
|
|
|
|
readString(ptr) {
|
|
if (!this.memory) return '';
|
|
const bytes = new Uint8Array(this.memory.buffer, ptr);
|
|
let length = 0;
|
|
while (bytes[length] !== 0) length++;
|
|
return new TextDecoder().decode(bytes.slice(0, length));
|
|
}
|
|
|
|
setMemory(memory) {
|
|
this.memory = memory;
|
|
}
|
|
|
|
setWasmInstance(instance) {
|
|
this.wasmInstance = instance;
|
|
}
|
|
|
|
// ===== Logging & Debug =====
|
|
|
|
_log(message) {
|
|
if (this.debugEnabled) {
|
|
this.log.push(message);
|
|
}
|
|
}
|
|
|
|
getLog() {
|
|
return this.log;
|
|
}
|
|
|
|
clearLog() {
|
|
this.log = [];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* RitzLoader - Simplified WASM loading
|
|
*/
|
|
class RitzLoader {
|
|
constructor(options = {}) {
|
|
this.rootElement = options.rootElement || document.getElementById('app');
|
|
this.statusElement = options.statusElement || document.getElementById('status');
|
|
this.onLoad = options.onLoad || (() => {});
|
|
this.onError = options.onError || ((err) => console.error(err));
|
|
}
|
|
|
|
async load(wasmPath, componentName, props = {}) {
|
|
try {
|
|
this.setStatus('⏳ Creating runtime...');
|
|
const runtime = new RitzRuntime(this.rootElement);
|
|
|
|
this.setStatus(`⏳ Loading ${wasmPath}...`);
|
|
const response = await fetch(wasmPath);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load ${wasmPath}: ${response.status}`);
|
|
}
|
|
|
|
const wasmBytes = await response.arrayBuffer();
|
|
|
|
this.setStatus('⏳ Instantiating WASM...');
|
|
|
|
// Create imports for WASM
|
|
const imports = {
|
|
env: {
|
|
js_create_element: (ptr) => runtime.js_create_element(ptr),
|
|
js_create_text: (ptr) => runtime.js_create_text(ptr),
|
|
js_create_text_with_int: (p, i) => runtime.js_create_text_with_int(p, i),
|
|
js_append_child: (p, c) => runtime.js_append_child(p, c),
|
|
js_set_attribute: (e, n, v) => runtime.js_set_attribute(e, n, v),
|
|
js_set_root: (e) => runtime.js_set_root(e),
|
|
js_bind_event: (e, n, h) => runtime.js_bind_event(e, n, h),
|
|
js_update_text_content: (n, t) => runtime.js_update_text_content(n, t),
|
|
js_update_text_with_int: (n, p, i) => runtime.js_update_text_with_int(n, p, i),
|
|
js_create_marker: () => runtime.js_create_marker(),
|
|
js_replace_at_marker: (m, o, n) => runtime.js_replace_at_marker(m, o, n),
|
|
}
|
|
};
|
|
|
|
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
|
|
|
|
// Setup runtime
|
|
runtime.setMemory(instance.exports.memory);
|
|
runtime.setWasmInstance(instance);
|
|
|
|
this.setStatus(`⏳ Rendering ${componentName}...`);
|
|
|
|
// Call the main entry point (which renders top-level component and sets root)
|
|
const mainFn = instance.exports.main;
|
|
if (!mainFn) {
|
|
throw new Error(`main function not found in WASM exports`);
|
|
}
|
|
|
|
mainFn();
|
|
|
|
// Update signals
|
|
runtime.updateSignals();
|
|
|
|
this.setStatus(`✅ ${componentName} loaded!`, 'success');
|
|
|
|
// Make runtime globally available for debugging
|
|
window.ritzRuntime = runtime;
|
|
window.ritzInstance = instance;
|
|
|
|
this.onLoad(runtime, instance);
|
|
|
|
return { runtime, instance };
|
|
|
|
} catch (error) {
|
|
this.setStatus(`❌ Error: ${error.message}`, 'error');
|
|
this.onError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
setStatus(message, type = '') {
|
|
if (this.statusElement) {
|
|
this.statusElement.textContent = message;
|
|
this.statusElement.className = 'status ' + type;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Export for use in modules or global scope
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { RitzRuntime, RitzLoader };
|
|
} else {
|
|
window.RitzRuntime = RitzRuntime;
|
|
window.RitzLoader = RitzLoader;
|
|
}
|