node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
247 lines (212 loc) • 9.54 kB
JavaScript
/**
* @kind module
* @module reactive
* @description Reactive proxy implementation for uibuilder (Loosely based on Vue.js v3 reactivity)
* @license Apache-2.0
* @author Julian Knight (Totally Information)
* @copyright (c) 2026 Julian Knight (Totally Information)
*/
/**
* Reactive proxy class that wraps variables/objects to make them reactive
* @class
*
* @example
* // Basic usage
* const reactiveData = new Reactive({ count: 0, user: { name: 'John' } })
* const proxy = reactiveData.create()
*
* // Listen to all changes
* const ref = proxy.onChange((newValue, oldValue, propertyPath, target) => {
* console.log(`Property ${propertyPath} changed from ${oldValue} to ${newValue}`)
* })
*
* // Changes will show full paths:
* proxy.count = 5 // propertyPath = "count"
* proxy.user.name = 'Jane' // propertyPath = "user.name"
*
* // Cancel the listener
* proxy.cancelChange(ref)
*/
export class Reactive {
static version = '2026-06-01' // Version of the reactive module
/** Create a new Reactive instance
* @param {*} srcvar The source variable to wrap
*/
constructor(srcvar) {
// If srcvar is not an object, wrap it in an object to make it proxy-able
this.target = typeof srcvar === 'object' && srcvar !== null ? srcvar : { value: srcvar, }
// Storage for change listeners - watches all changes
this.changeListeners = new Map()
this.listenerIdCounter = 0
}
/** Helper function to check if an object is already reactive
* @param {*} obj Object to check
* @returns {boolean} True if the object is already reactive
* @private
*/
_isReactive(obj) {
return obj && obj.__v_isReactive === true
}
/** Helper function to trigger all change listeners
* @param {string} propertyPath Full property path (e.g., "user.name")
* @param {*} value New value
* @param {*} oldValue Previous value
* @param {*} target The target object
* @private
*/
_triggerListeners(propertyPath, value, oldValue, target) {
this.changeListeners.forEach((callback) => {
try {
callback(value, oldValue, propertyPath, target)
} catch (error) {
console.warn(`[uibuilder:reactive] Error in onChange listener for "${propertyPath}":`, error)
}
})
}
/** Helper function to make an object reactive with property path tracking
* @param {*} obj Object to make reactive
* @param {string} basePath Base property path for nested objects
* @returns {Proxy|*} Reactive proxy object
* @private
*/
_createReactiveObject(obj, basePath = '') {
if (!obj || typeof obj !== 'object') return obj
if (this._isReactive(obj)) return obj
// Don't proxy DOM elements, or other special objects
if ( (Element && obj instanceof Element) || obj instanceof Date || obj instanceof RegExp) {
console.warn('[uibuilder:reactive] Can not proxy DOM elements or other special objects')
return obj
}
const proxy = new Proxy(obj, {
get: (target, key, receiver) => {
// Mark as reactive
if (key === '__v_isReactive') return true
// Add onChange method to the proxy - simplified to watch all changes
if (key === 'onChange') {
return (/** @type {Function} */ callback) => {
if (typeof callback !== 'function') {
throw new Error('[uibuilder:reactive] onChange callback must be a function')
}
const listenerId = ++this.listenerIdCounter
this.changeListeners.set(listenerId, callback)
// Return a reference object that can be used to cancel the listener
return {
id: listenerId,
cancel: () => {
this.changeListeners.delete(listenerId)
},
}
}
}
// Add cancelChange method to the proxy
if (key === 'cancelChange') {
return (/** @type {{ cancel: () => void; }} */ listenerRef) => { // eslint-disable-line jsdoc/valid-types
if (!listenerRef || typeof listenerRef.cancel !== 'function') {
console.warn('[uibuilder:reactive] Invalid listener reference provided to cancelChange')
return false
}
try {
listenerRef.cancel()
return true
} catch (error) {
console.warn('[uibuilder:reactive] Error cancelling listener:', error)
return false
}
}
}
const result = Reflect.get(target, key, receiver)
// If the result is an object, make it reactive too with the extended path
if (result && typeof result === 'object' && !this._isReactive(result)) {
const newPath = basePath ? `${basePath}.${String(key)}` : String(key)
return this._createReactiveObject(result, newPath)
}
return result
},
set: (target, key, value, receiver) => {
// Don't allow setting the reactive marker or special methods
if (key === '__v_isReactive' || key === 'onChange' || key === 'cancelChange') return true
const oldValue = target[key]
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const result = Reflect.set(target, key, value, receiver)
// Only trigger effects if the value actually changed or it's a new property
if (!hadKey || value !== oldValue) {
// Create the full property path
const propertyPath = basePath ? `${basePath}.${String(key)}` : String(key)
// Trigger all change listeners with the full property path
this._triggerListeners(propertyPath, value, oldValue, receiver)
// Dispatch a custom event for the property change if dispatcher provided
try {
this._dispatchCustomEvent('uibuilder:reactive:propertyChanged', {
property: propertyPath,
value,
oldValue,
target: receiver,
})
} catch (error) {
console.warn('[uibuilder:reactive] Error dispatching custom event:', error)
}
}
return result
},
deleteProperty: (target, key) => {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const oldValue = target[key]
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) {
// Create the full property path
const propertyPath = basePath ? `${basePath}.${String(key)}` : String(key)
// Trigger all change listeners for deletion with the full property path
this._triggerListeners(propertyPath, undefined, oldValue, target)
// Dispatch a custom event for the property deletion if dispatcher provided
try {
this._dispatchCustomEvent('uibuilder:reactive:propertyDeleted', {
property: propertyPath,
oldValue,
target,
})
} catch (error) {
console.warn('[uibuilder:reactive] Error dispatching delete event:', error)
}
}
return result
},
})
return proxy
}
/** Standard fn to create a custom event with details & dispatch it
* @param {string} title The event name
* @param {*} details Any details to pass to event output
* @private
*/
_dispatchCustomEvent(title, details) {
const event = new CustomEvent(title, { detail: details, })
document.dispatchEvent(event)
}
/** Create and return the reactive proxy
* @returns {Proxy} A proxy object that can be used reactively
*/
create() {
return this._createReactiveObject(this.target)
}
/** Get the number of active listeners
* @returns {number} Number of active change listeners
*/
getListenerCount() {
return this.changeListeners.size
}
/** Clear all listeners
* @returns {void}
*/
clearAllListeners() {
this.changeListeners.clear()
}
}
/** Convenience function to create a reactive proxy (maintains backward compatibility)
* @param {*} srcvar The source variable to wrap
* @returns {Proxy} A proxy object that can be used reactively
*/
export function reactive(srcvar) {
const reactiveInstance = new Reactive(srcvar)
return reactiveInstance.create()
}
export default Reactive