Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Change to generic approach
  • Loading branch information
benceruleanlu committed Jul 23, 2025
commit 838a185442b26429ecfcda36fa4b83ea07c11e1f
153 changes: 114 additions & 39 deletions src/nodePropertyInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,133 @@
import type { LGraphNode } from "./LGraphNode"

/**
* Instruments a node to emit events when specific properties change.
* Uses Object.defineProperty to intercept property assignments without
* affecting performance of hot-path properties.
* Configuration for tracking property changes
*/
interface PropertyConfig {
/** The property path (e.g., "title" or "flags.collapsed") */
path: string
/** Initial value or getter function */
defaultValue?: any
/** Type of the property for validation (optional) */
type?: "string" | "boolean" | "number" | "object"
}

/**
* Default properties to track - can be extended or overridden
*/
const DEFAULT_TRACKED_PROPERTIES: PropertyConfig[] = [
{ path: "title", type: "string" },
{ path: "flags.collapsed", defaultValue: false, type: "boolean" },
]

/**
* Creates a property descriptor that emits change events
*/
export function instrumentNodeProperties(node: LGraphNode): void {
// Track title changes
let _title = node.title
Object.defineProperty(node, "title", {
function createInstrumentedProperty(
node: LGraphNode,
propertyPath: string,
initialValue: any,
): PropertyDescriptor {
let value = initialValue

return {
get() {
return _title
return value
},
set(value: string) {
const oldValue = _title
_title = value
if (oldValue !== value && node.graph) {
// Emit via graph's trigger mechanism
set(newValue: any) {
const oldValue = value
value = newValue
if (oldValue !== newValue && node.graph) {
node.graph.trigger("node:property:changed", {
nodeId: node.id,
property: "title",
property: propertyPath,
oldValue,
newValue: value,
newValue,
})
}
},
enumerable: true,
configurable: true,
})
}
}

/**
* Ensures parent objects exist for nested properties
*/
function ensureNestedPath(obj: any, path: string): void {
const parts = path.split(".")
let current = obj

// Ensure flags object exists
if (!node.flags) {
node.flags = {}
// Create all parent objects except the last property
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
if (!current[part]) {
current[part] = {}
}
current = current[part]
}
}

// Track flags.collapsed changes
let _collapsed = node.flags.collapsed || false
Object.defineProperty(node.flags, "collapsed", {
get() {
return _collapsed
},
set(value: boolean) {
const oldValue = _collapsed
_collapsed = value
if (oldValue !== value && node.graph) {
node.graph.trigger("node:property:changed", {
nodeId: node.id,
property: "flags.collapsed",
oldValue,
newValue: value,
})
/**
* Instruments a node to emit events when specific properties change.
* Uses Object.defineProperty to intercept property assignments without
* affecting performance of hot-path properties.
* @param node The node to instrument
* @param trackedProperties Array of properties to track (defaults to DEFAULT_TRACKED_PROPERTIES)
*/
export function instrumentNodeProperties(
node: LGraphNode,
trackedProperties: PropertyConfig[] = DEFAULT_TRACKED_PROPERTIES,
): void {
for (const config of trackedProperties) {
const parts = config.path.split(".")

// Ensure nested path exists
if (parts.length > 1) {
ensureNestedPath(node, config.path)
}

// Get the parent object and property name
let targetObject: any = node
let propertyName = parts[0]

if (parts.length > 1) {
// Navigate to parent object for nested properties
for (let i = 0; i < parts.length - 1; i++) {
targetObject = targetObject[parts[i]]
}
},
enumerable: true,
configurable: true,
})
propertyName = parts.at(-1)!
}

// Get initial value
const currentValue = targetObject[propertyName]
const initialValue = currentValue !== undefined
? currentValue
: config.defaultValue

// Create and apply the property descriptor
Object.defineProperty(
targetObject,
propertyName,
createInstrumentedProperty(node, config.path, initialValue),
)
}
}

/**
* Helper function to add additional tracked properties to the default set
*/
export function addTrackedProperty(config: PropertyConfig): void {
// Check if property is already tracked
const exists = DEFAULT_TRACKED_PROPERTIES.some(p => p.path === config.path)
if (!exists) {
DEFAULT_TRACKED_PROPERTIES.push(config)
}
}

/**
* Helper function to get the current set of tracked properties
*/
export function getTrackedProperties(): PropertyConfig[] {
return [...DEFAULT_TRACKED_PROPERTIES]
}
133 changes: 133 additions & 0 deletions test/nodePropertyInstrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,137 @@ describe("Node Property Instrumentation", () => {
expect(Object.keys(node!)).toContain("title")
expect(Object.keys(node!.flags)).toContain("collapsed")
})

describe("Generic property tracking", () => {
it("should allow tracking custom properties", async () => {
// Dynamically import to get fresh module state
const { instrumentNodeProperties, addTrackedProperty } = await import(
"@/nodePropertyInstrumentation",
)

// Add custom tracked properties that don't exist on the node
addTrackedProperty({ path: "customData", type: "object" })
addTrackedProperty({ path: "priority", defaultValue: 0, type: "number" })
addTrackedProperty({ path: "flags.readonly", defaultValue: false, type: "boolean" })

const graph = new LGraph()
const mockTrigger = vi.fn()
graph.onTrigger = mockTrigger

const node = LiteGraph.createNode("test/node") as any
graph.add(node!)

// Re-instrument the node with the new properties
instrumentNodeProperties(node!)

// Test custom properties
node!.customData = { value: 42 }
expect(mockTrigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: node!.id,
property: "customData",
oldValue: undefined,
newValue: { value: 42 },
})

node!.priority = 5
expect(mockTrigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: node!.id,
property: "priority",
oldValue: 0,
newValue: 5,
})

node!.flags.readonly = true
expect(mockTrigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: node!.id,
property: "flags.readonly",
oldValue: false,
newValue: true,
})
})

it("should support deeply nested properties", async () => {
const { instrumentNodeProperties } = await import("@/nodePropertyInstrumentation")

const graph = new LGraph()
const mockTrigger = vi.fn()
graph.onTrigger = mockTrigger

const node = LiteGraph.createNode("test/node") as any
graph.add(node!)

// Track a deeply nested property
const customProperties: Array<{
path: string
defaultValue?: any
type?: "string" | "boolean" | "number" | "object"
}> = [{ path: "config.ui.theme.color", defaultValue: "light", type: "string" }]

instrumentNodeProperties(node!, customProperties)

// Test deeply nested property
node!.config.ui.theme.color = "dark"
expect(mockTrigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: node!.id,
property: "config.ui.theme.color",
oldValue: "light",
newValue: "dark",
})

// Verify nested structure was created
expect(node!.config).toBeDefined()
expect(node!.config.ui).toBeDefined()
expect(node!.config.ui.theme).toBeDefined()
expect(node!.config.ui.theme.color).toBe("dark")
})

it("should handle property array configuration", async () => {
const { instrumentNodeProperties } = await import("@/nodePropertyInstrumentation")

const graph = new LGraph()
const mockTrigger = vi.fn()
graph.onTrigger = mockTrigger

const node = LiteGraph.createNode("test/node") as any
graph.add(node!)

// Define multiple properties at once
const trackedProperties: Array<{
path: string
defaultValue?: any
type?: "string" | "boolean" | "number" | "object"
}> = [
{ path: "category", defaultValue: "default", type: "string" },
{ path: "metadata", defaultValue: {}, type: "object" },
{ path: "flags.pinned", defaultValue: false, type: "boolean" },
]

instrumentNodeProperties(node!, trackedProperties)

// Test all tracked properties
node!.category = "advanced"
node!.metadata = { version: 1, author: "test" }
node!.flags.pinned = true

expect(mockTrigger).toHaveBeenCalledTimes(3)
expect(mockTrigger).toHaveBeenNthCalledWith(1, "node:property:changed", {
nodeId: node!.id,
property: "category",
oldValue: "default",
newValue: "advanced",
})
expect(mockTrigger).toHaveBeenNthCalledWith(2, "node:property:changed", {
nodeId: node!.id,
property: "metadata",
oldValue: {},
newValue: { version: 1, author: "test" },
})
expect(mockTrigger).toHaveBeenNthCalledWith(3, "node:property:changed", {
nodeId: node!.id,
property: "flags.pinned",
oldValue: false,
newValue: true,
})
})
})
})