UNPKG

jelenjs

Version:

Core runtime library for JelenJS - an experimental UI framework with fine-grained reactivity

725 lines (716 loc) 25.3 kB
/** * Debug configuration for JelenJS reactive system * Uses environment variables for conditional compilation */ // Main debug flag - will be replaced by bundler const DEBUG_MODE = false ; // Debug utility functions that will be completely removed in production const debugLog = () => { }; function debugEffect(effectId, message, ...args) { } function debugConstants(message, ...args) { } /** * Constants and global state for reactivity system */ // Create a global singleton to ensure all modules share the same state const GLOBAL_KEY = '__JELENJS_REACTIVE_STATE__'; // Get or create global state function getGlobalState() { if (typeof globalThis !== 'undefined') { if (!globalThis[GLOBAL_KEY]) { globalThis[GLOBAL_KEY] = { currentEffect: null, currentContext: null, microtaskPending: false, globalEffectId: 0 }; } return globalThis[GLOBAL_KEY]; } // Fallback for environments without globalThis if (typeof window !== 'undefined') { if (!window[GLOBAL_KEY]) { window[GLOBAL_KEY] = { currentEffect: null, currentContext: null, microtaskPending: false, globalEffectId: 0 }; } return window[GLOBAL_KEY]; } // Final fallback - create local state (not ideal but better than nothing) return { currentEffect: null, currentContext: null, microtaskPending: false, globalEffectId: 0 }; } const globalState = getGlobalState(); // Export getters and setters that use the global state let currentEffect = globalState.currentEffect; let currentContext = globalState.currentContext; globalState.microtaskPending; globalState.globalEffectId; // Effect states using bitmasks (inspired by SolidJS) const CLEAN = 0; // Effect is clean (no updates needed) // Updater functions function setCurrentEffect(value) { debugConstants(`Current effect changing from #${globalState.currentEffect?.id ?? 'null'} to #${value?.id ?? 'null'}`); globalState.currentEffect = value; currentEffect = value; // Keep local variable in sync return globalState.currentEffect; } function incrementEffectId() { globalState.globalEffectId++; globalState.globalEffectId; // Keep local variable in sync debugConstants(`Created new effect #${globalState.globalEffectId}`); return globalState.globalEffectId; } /** * Signal implementation for reactive state management */ // Debug flag is now imported from debug.ts // Global signal registry to ensure all modules share the same signal tracking const SIGNAL_REGISTRY_KEY = '__JELENJS_SIGNAL_REGISTRY__'; function getGlobalSignalRegistry() { if (typeof globalThis !== 'undefined') { if (!globalThis[SIGNAL_REGISTRY_KEY]) { globalThis[SIGNAL_REGISTRY_KEY] = new Set(); } return globalThis[SIGNAL_REGISTRY_KEY]; } if (typeof window !== 'undefined') { if (!window[SIGNAL_REGISTRY_KEY]) { window[SIGNAL_REGISTRY_KEY] = new Set(); } return window[SIGNAL_REGISTRY_KEY]; } // Fallback return new Set(); } const globalSignalRegistry = getGlobalSignalRegistry(); // Symbol to mark signal getter functions (more reliable than WeakSet) const SIGNAL_GETTER_SYMBOL = Symbol('signalGetter'); /** * Check if a function is a signal getter */ function isSignalGetter(fn) { if (typeof fn !== 'function') return false; // Check Symbol first (most reliable) if (fn[SIGNAL_GETTER_SYMBOL] === true) { return true; } // Check global registry as fallback return globalSignalRegistry.has(fn); } /** * Effect implementation for reactive computations */ /** * Creates an effect that tracks reactive dependencies * * @param fn The function to run as an effect * @returns A dispose function that can be called to clean up the effect */ function createEffect(fn) { // Create the effect function const effect = () => { // Skip if already disposed if (effect.disposed) { debugEffect(effect.id); return; } debugEffect(effect.id); // Run cleanup functions if any if (effect.cleanupFns && effect.cleanupFns.length > 0) { debugEffect(effect.id, `Running ${effect.cleanupFns.length} cleanup functions`); for (let i = 0; i < effect.cleanupFns.length; i++) { effect.cleanupFns[i](); } effect.cleanupFns = []; } // Clear previous dependencies if (effect.sources && effect.sources.length > 0) { debugEffect(effect.id, `Clearing ${effect.sources.length} sources`); // Remove this effect from all previous sources for (let i = 0; i < effect.sources.length; i++) { const sourceNode = effect.sources[i]; sourceNode.subscribers.delete(effect); } effect.sources.length = 0; // Clear array effect.sourceSlots.length = 0; // Clear slots } // Prevent recursive effect execution if (effect.running) { debugEffect(effect.id); return; } effect.running = true; // Set as current effect during execution to track dependencies const prevEffect = currentEffect; setCurrentEffect(effect); debugEffect(effect.id, 'Set as currentEffect:', currentEffect); try { // Run the effect function - this will track dependencies via signal getters const result = fn(); // Handle cleanup function returned from effect if (typeof result === 'function') { debugEffect(effect.id, 'Registered cleanup function'); effect.cleanupFns.push(result); } } catch (error) { console.error(`[EFFECT #${effect.id}] Error in effect:`, error); } finally { // Restore previous effect setCurrentEffect(prevEffect); effect.running = false; debugEffect(effect.id, 'Restored currentEffect to:', currentEffect); } }; // Initialize effect properties effect.id = incrementEffectId(); effect.disposed = false; effect.running = false; effect.state = CLEAN; effect.cleanupFns = []; effect.sources = []; // Array instead of Set effect.sourceSlots = []; // Track positions for O(1) removal effect.updatedAt = null; // Track when last updated effect.parentContext = currentContext; debugEffect(effect.id); // Register with parent context if available if (currentContext && currentContext.effects) { currentContext.effects.add(effect); debugEffect(effect.id); } // Dispose function to clean up the effect effect.dispose = () => { if (effect.disposed) { debugEffect(effect.id); return; } debugEffect(effect.id); effect.disposed = true; // Run cleanup functions if (effect.cleanupFns.length > 0) { debugEffect(effect.id, `Running ${effect.cleanupFns.length} cleanup functions before disposal`); for (let i = 0; i < effect.cleanupFns.length; i++) { effect.cleanupFns[i](); } effect.cleanupFns = []; } // Remove from all sources if (effect.sources && effect.sources.length > 0) { debugEffect(effect.id, `Removing from ${effect.sources.length} sources`); for (let i = 0; i < effect.sources.length; i++) { const sourceNode = effect.sources[i]; sourceNode.subscribers.delete(effect); } effect.sources.length = 0; // Clear array effect.sourceSlots.length = 0; // Clear slots } // Remove from parent context if (effect.parentContext && effect.parentContext.effects) { effect.parentContext.effects.delete(effect); debugEffect(effect.id); } }; // Initial execution to establish dependencies debugEffect(effect.id); effect(); // Return the dispose function return effect.dispose; } /** * Shared utility functions for JelenJS */ /** * Helper to detect if an object is a signal */ function isSignal(value) { // Signal detection for the new implementation if (value === null || value === undefined) { return false; } // Check if it's a signal tuple [getter, setter] if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'function' && typeof value[1] === 'function') { return true; } // Check if it's a getter function marked as a signal getter if (isSignalGetter(value)) { return true; } return false; } /** * Helper to safely get value from a signal * Works with both getter functions and signal tuples * For class attributes, converts boolean value to string ('active' or '') */ function getSignalValue(signal, isClassAttr = false) { let rawValue; if (typeof signal === 'function') { try { // Try to get the value by calling the function rawValue = signal(); if (DEBUG_MODE) ; } catch (e) { rawValue = signal; } } else if (Array.isArray(signal) && typeof signal[0] === 'function') { try { // Try to get the value from the first element of the tuple rawValue = signal[0](); if (DEBUG_MODE) ; } catch (e) { rawValue = signal; } } else { rawValue = signal; } // For class attributes, convert boolean to string value if (isClassAttr && typeof rawValue === 'boolean') { return (rawValue ? 'active' : ''); } return rawValue; } /** * Simplified DOM handling utilities */ // Store of elements that have signal attributes const signalElementRegistry = new Map(); /** * Create an HTML element with the given tag name, props, and children */ function h(tag, props, ...children) { const element = document.createElement(tag); // Set properties and attributes on the element if (props) { for (const [key, value] of Object.entries(props)) { if (key === 'style' && typeof value === 'object') { // Handle style object Object.assign(element.style, value); } else if (key.startsWith('on') && typeof value === 'function') { // Handle event listeners const eventName = key.slice(2).toLowerCase(); element.addEventListener(eventName, value); } else if (isSignal(value)) { // Handle signals const initialValue = getSignalValue(value); updateProperty(element, key, initialValue); // Track signals for this element if (!signalElementRegistry.has(element)) { signalElementRegistry.set(element, new Map()); } signalElementRegistry.get(element).set(key, value); // Create an effect to keep the DOM updated createEffect(() => { const signalValue = getSignalValue(value); updateProperty(element, key, signalValue); }); } else { // Handle regular properties and attributes if (key === 'class' || key === 'className') { element.className = String(value); } else if (key.startsWith('data-')) { element.setAttribute(key, String(value)); } else { element[key] = value; } } } } // Add children for (const child of children) { appendChildToElement(element, child); } return element; } /** * Check if a value is a signal or a function (which might be a signal accessor) */ function isSignalOrFunction(value) { // Skip specific values that we know are not reactive if (value === null || value === undefined || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return false; } // Check if it's a signal (tuple or marked getter function) if (isSignal(value)) { return true; } // Check if it's a function that might be a signal getter if (typeof value === 'function') { // If it's marked as a signal getter, it's definitely reactive if (isSignalGetter(value)) { return true; } // For other functions, we'll treat them as potentially reactive // This handles cases where functions might be signal getters return true; } return false; } /** * Append a child to an element, with special handling for different types */ function appendChildToElement(element, child) { if (child == null) return; // Handle arrays (multiple children or from conditional expressions) if (Array.isArray(child)) { for (const subChild of child) { appendChildToElement(element, subChild); } return; } // Handle DOM nodes directly if (child instanceof Node) { element.appendChild(child); return; } // Handle signals and functions (both {count} and {count()} syntaxes) if (isSignalOrFunction(child)) { const container = document.createElement('span'); container.style.display = 'contents'; // Make it invisible in the layout element.appendChild(container); // Create an effect to update when signal changes createEffect(() => { if (DEBUG_MODE) ; // FIXED: Call the signal getter INSIDE the effect to establish dependency let value; if (typeof child === 'function') { if (DEBUG_MODE) ; value = child(); // This will track the dependency } else if (Array.isArray(child) && typeof child[0] === 'function') { if (DEBUG_MODE) ; value = child[0](); // This will track the dependency } else if (isSignal(child)) { if (DEBUG_MODE) ; value = getSignalValue(child); // This will track the dependency } else { value = child; } if (DEBUG_MODE) ; // Clear previous content container.innerHTML = ''; // Handle different types of values if (value instanceof Node) { container.appendChild(value); } else if (value instanceof Element) { container.appendChild(value); } else if (Array.isArray(value)) { for (const item of value) { appendChildToElement(container, item); } } else if (value === false || value === null || value === undefined) { // For falsy values in conditional rendering, don't render anything } else if (value != null) { container.textContent = String(value); } }); return; } // Handle primitive values element.appendChild(document.createTextNode(String(child))); } /** * Helper to update a property or attribute on an element */ function updateProperty(element, key, value) { if (key === 'class' || key === 'className') { element.className = String(value); } else if (key === 'style' && typeof value === 'object') { Object.assign(element.style, value); } else if (key === 'disabled' || key === 'checked' || key === 'required') { // Handle boolean attributes if (value) { element.setAttribute(key, ''); } else { element.removeAttribute(key); } } else if (key.startsWith('data-')) { element.setAttribute(key, String(value)); } else { // For other properties, set them directly element[key] = value; // Also set attribute for visibility in DOM if (typeof value !== 'object' && value !== undefined) { element.setAttribute(key, String(value)); } } } /** * Component Lifecycle for JelenJS */ // Global state key for component lifecycle const GLOBAL_COMPONENT_STATE_KEY = '__JELENJS_COMPONENT_STATE__'; // Initialize global component state function getGlobalComponentState() { if (typeof globalThis !== 'undefined') { if (!globalThis[GLOBAL_COMPONENT_STATE_KEY]) { globalThis[GLOBAL_COMPONENT_STATE_KEY] = { currentComponent: null, mountCallbacks: new WeakMap(), unmountCallbacks: new WeakMap() }; } return globalThis[GLOBAL_COMPONENT_STATE_KEY]; } if (typeof window !== 'undefined') { if (!window[GLOBAL_COMPONENT_STATE_KEY]) { window[GLOBAL_COMPONENT_STATE_KEY] = { currentComponent: null, mountCallbacks: new WeakMap(), unmountCallbacks: new WeakMap() }; } return window[GLOBAL_COMPONENT_STATE_KEY]; } // Fallback for non-browser environments return { currentComponent: null, mountCallbacks: new WeakMap(), unmountCallbacks: new WeakMap() }; } const globalComponentState = getGlobalComponentState(); /** * Set the current component context (used internally) */ function setCurrentComponent(component) { console.log('[jelenjs-lifecycle] setCurrentComponent called, component:', component); globalComponentState.currentComponent = component; } /** * Mark a component as mounted and run mount callbacks * @param component The component instance */ function markAsMounted(component) { console.log('[jelenjs-lifecycle] markAsMounted called, component:', component); const callbacks = globalComponentState.mountCallbacks.get(component); if (callbacks) { callbacks.forEach((callback) => { try { callback(); } catch (error) { console.error('Error in onMount callback:', error); } }); // Clear callbacks after execution globalComponentState.mountCallbacks.delete(component); } } /** * Simplified JSX implementation for JelenJS */ /** * Handle reactive expressions in JSX props and children */ function processReactiveValue(value) { // If it's a function (which might be a signal accessor like count()), // try to call it and get the current value if (typeof value === 'function') { try { const result = value(); return result; } catch (e) { // If it fails, it's not a signal accessor function return value; } } // For regular signals, use getSignalValue if (isSignal(value)) { const result = getSignalValue(value); return result; } return value; } /** * Simple check for conditional expressions in runtime * This is a lightweight runtime check, not a full AST analysis */ function looksLikeConditionalExpression(expr) { // Simple pattern matching for common conditional patterns return /\?\s*.*\s*:/.test(expr) || /&&/.test(expr) || /\|\|/.test(expr); } /** * Auto-wrap conditional expressions for reactivity */ function autoWrapConditional(expr) { // If it's already a function, return it as-is if (typeof expr === 'function') { return expr; } // If it's a string that looks like a conditional expression with signals if (typeof expr === 'string' && looksLikeConditionalExpression(expr)) { // Create a function that evaluates the expression in the current scope // This is experimental and requires the signals to be in global scope try { return new Function('count', `return ${expr}`); } catch (e) { return () => expr; } } // Return a simple function that returns the value return () => expr; } /** * Process JSX children and make them reactive when needed */ function processJSXChildren(children) { if (children == null) return []; // Handle arrays of children if (Array.isArray(children)) { return children.flatMap(processJSXChildren); } // Handle conditional expressions - the key part for automatic conditional rendering if (typeof children === 'object' && children !== null && (('operator' in children && children.operator === '&&') || ('test' in children && 'consequent' in children && 'alternate' in children))) { // Create a reactive function that will evaluate the condition const reactiveCondition = () => { if ('operator' in children && children.operator === '&&') { // For logical AND expressions (condition && <Element/>) const testValue = processReactiveValue(children.left); return testValue ? children.right : null; } else if ('test' in children) { // For ternary expressions (condition ? <TrueElement/> : <FalseElement/>) const testValue = processReactiveValue(children.test); return testValue ? children.consequent : children.alternate; } return null; }; return [reactiveCondition]; } // EXPERIMENTAL: Check if this might be a conditional expression string if (typeof children === 'string' && looksLikeConditionalExpression(children)) { // Auto-wrap the conditional expression return [autoWrapConditional(children)]; } // Check if this is a simple boolean result of a condition if (typeof children === 'boolean') { return [children]; } // Check if this might be a signal or a function that should be reactive if (typeof children === 'function' || isSignal(children)) { return [children]; // Pass the original signal directly, don't wrap it } return [children]; } /** * Factory function for JSX elements */ function jsx(tag, props = {}) { // Extract children from props const { children, ...restProps } = props; // Handle function components if (typeof tag === 'function') { // Set component context for lifecycle hooks const componentInstance = tag; setCurrentComponent(componentInstance); try { const result = tag({ ...restProps, children }); // Schedule mount callbacks to run after the component is rendered setTimeout(() => { markAsMounted(componentInstance); }, 0); return result; } finally { // Clear component context setCurrentComponent(null); } } // Process children const childrenArray = children === undefined ? [] : processJSXChildren(children); // Create element with hyperscript function return h(tag, restProps, ...childrenArray); } // Alias for JSX with spread children const jsxs = jsx; // JSX Fragment implementation const Fragment = (props) => { if (!props.children) { return document.createDocumentFragment(); } const fragment = document.createDocumentFragment(); // Process children const childrenArray = processJSXChildren(props.children); for (const child of childrenArray) { if (child instanceof Node || child instanceof Element) { fragment.appendChild(child); } else if (typeof child === 'function') { // Handle reactive functions (from conditional expressions) const container = document.createElement('span'); container.style.display = 'contents'; fragment.appendChild(container); // Set up effect to reactively update the content createEffect(() => { try { const result = child(); // Clear container container.innerHTML = ''; // Update with new content if (result instanceof Node) { container.appendChild(result); } else if (result != null) { container.textContent = String(result); } } catch (e) { // Handle errors silently } }); } else if (child != null) { fragment.appendChild(document.createTextNode(String(child))); } } return fragment; }; export { Fragment, jsx, jsxs }; //# sourceMappingURL=jsx-runtime.js.map