refactor: migrate ES private fields to TypeScript private for Vue Proxy compatibility (#8440)

## Summary

Migrates ECMAScript private fields (`#`) to TypeScript private
(`private`) across LiteGraph to fix Vue Proxy reactivity
incompatibility.

## Problem

ES private fields (`#field`) are incompatible with Vue's Proxy-based
reactivity system - accessing `#field` through a Proxy throws
`TypeError: Cannot read private member from an object whose class did
not declare it`.

## Solution

- Converted all `#field` to `private _field` across 10 phases
- Added `toJSON()` methods to `LGraph`, `NodeSlot`, `NodeInputSlot`, and
`NodeOutputSlot` to prevent circular reference errors during
serialization (TypeScript private fields are visible to `JSON.stringify`
unlike true ES private fields)
- Made `DragAndScale.element.data` non-enumerable to break canvas
circular reference chain

## Testing

- All 4027 unit tests pass
- Added 9 new serialization tests to catch future circular reference
issues
- Browser tests (undo/redo, save workflows) verified working

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8440-refactor-migrate-ES-private-fields-to-TypeScript-private-for-Vue-Proxy-compatibility-2f76d73d365081a3bd82d429a3e0fcb7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-29 18:18:58 -08:00
committed by GitHub
parent 82bacb82a7
commit 067d80c4ed
28 changed files with 653 additions and 705 deletions

View File

@@ -273,8 +273,8 @@ export class LGraphNode
inputs: INodeInputSlot[] = []
outputs: INodeOutputSlot[] = []
#concreteInputs: NodeInputSlot[] = []
#concreteOutputs: NodeOutputSlot[] = []
private _concreteInputs: NodeInputSlot[] = []
private _concreteOutputs: NodeOutputSlot[] = []
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
@@ -438,24 +438,24 @@ export class LGraphNode
}
/** @inheritdoc {@link renderArea} */
#renderArea = new Rectangle()
private _renderArea = new Rectangle()
/**
* Rect describing the node area, including shadows and any protrusions.
* Determines if the node is visible. Calculated once at the start of every frame.
*/
get renderArea(): ReadOnlyRect {
return this.#renderArea
return this._renderArea
}
/** @inheritdoc {@link boundingRect} */
#boundingRect: Rectangle = new Rectangle()
private _boundingRect: Rectangle = new Rectangle()
/**
* Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present.
*
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
*/
get boundingRect(): ReadOnlyRectangle {
return this.#boundingRect
return this._boundingRect
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
@@ -753,7 +753,9 @@ export class LGraphNode
onPropertyChange?(this: LGraphNode): void
updateOutputData?(this: LGraphNode, origin_slot: number): void
#getErrorStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
private _getErrorStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.has_errors) {
return {
padding: 12,
@@ -763,7 +765,9 @@ export class LGraphNode
}
}
#getSelectedStrokeStyle(this: LGraphNode): IDrawBoundingOptions | undefined {
private _getSelectedStrokeStyle(
this: LGraphNode
): IDrawBoundingOptions | undefined {
if (this.selected) {
return {
padding: this.has_errors ? 20 : undefined
@@ -778,8 +782,8 @@ export class LGraphNode
this.size = [LiteGraph.NODE_WIDTH, 60]
this.pos = [10, 10]
this.strokeStyles = {
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle
error: this._getErrorStrokeStyle,
selected: this._getSelectedStrokeStyle
}
// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
@@ -2067,11 +2071,11 @@ export class LGraphNode
* Called automatically at the start of every frame.
*/
updateArea(ctx?: CanvasRenderingContext2D): void {
const bounds = this.#boundingRect
const bounds = this._boundingRect
this.measure(bounds, ctx)
this.onBounding?.(bounds)
const renderArea = this.#renderArea
const renderArea = this._renderArea
renderArea.set(bounds)
// 4 offset for collapsed node connection points
renderArea[0] -= 4
@@ -2293,7 +2297,7 @@ export class LGraphNode
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeInputSlot | -1
findInputSlotFree(optsIn?: FindFreeSlotOptions) {
return this.#findFreeSlot(this.inputs, optsIn)
return this._findFreeSlot(this.inputs, optsIn)
}
/**
@@ -2308,14 +2312,14 @@ export class LGraphNode
optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }
): INodeOutputSlot | -1
findOutputSlotFree(optsIn?: FindFreeSlotOptions) {
return this.#findFreeSlot(this.outputs, optsIn)
return this._findFreeSlot(this.outputs, optsIn)
}
/**
* Finds the next free slot
* @param slots The slots to search, i.e. this.inputs or this.outputs
*/
#findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
private _findFreeSlot<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
options?: FindFreeSlotOptions
): TSlot | number {
@@ -2357,7 +2361,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this.#findSlotByType(
return this._findSlotByType(
this.inputs,
type,
returnObj,
@@ -2387,7 +2391,7 @@ export class LGraphNode
preferFreeSlot?: boolean,
doNotUseOccupied?: boolean
) {
return this.#findSlotByType(
return this._findSlotByType(
this.outputs,
type,
returnObj,
@@ -2433,14 +2437,14 @@ export class LGraphNode
doNotUseOccupied?: boolean
): number | INodeOutputSlot | INodeInputSlot {
return input
? this.#findSlotByType(
? this._findSlotByType(
this.inputs,
type,
returnObj,
preferFreeSlot,
doNotUseOccupied
)
: this.#findSlotByType(
: this._findSlotByType(
this.outputs,
type,
returnObj,
@@ -2461,7 +2465,7 @@ export class LGraphNode
* @see {findInputSlotByType}
* @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1
*/
#findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
private _findSlotByType<TSlot extends INodeInputSlot | INodeOutputSlot>(
slots: TSlot[],
type: ISlotType,
returnObj?: boolean,
@@ -3310,8 +3314,8 @@ export class LGraphNode
// default vertical slots
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotIndex = is_input
? this.#defaultVerticalInputs.indexOf(this.inputs[slot_number])
: this.#defaultVerticalOutputs.indexOf(this.outputs[slot_number])
? this._defaultVerticalInputs.indexOf(this.inputs[slot_number])
: this._defaultVerticalOutputs.indexOf(this.outputs[slot_number])
out[0] = is_input ? nodeX + offset : nodeX + this.size[0] + 1 - offset
out[1] =
@@ -3324,7 +3328,7 @@ export class LGraphNode
/**
* @internal The inputs that are not positioned with absolute coordinates.
*/
get #defaultVerticalInputs() {
private get _defaultVerticalInputs() {
return this.inputs.filter(
(slot) => !slot.pos && !(this.widgets?.length && isWidgetInputSlot(slot))
)
@@ -3333,7 +3337,7 @@ export class LGraphNode
/**
* @internal The outputs that are not positioned with absolute coordinates.
*/
get #defaultVerticalOutputs() {
private get _defaultVerticalOutputs() {
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
@@ -3341,7 +3345,7 @@ export class LGraphNode
* Get the context needed for slot position calculations
* @internal
*/
#getSlotPositionContext(): SlotPositionContext {
private _getSlotPositionContext(): SlotPositionContext {
return {
nodeX: this.pos[0],
nodeY: this.pos[1],
@@ -3373,7 +3377,7 @@ export class LGraphNode
* @returns Position of the centre of the input slot in graph co-ordinates.
*/
getInputSlotPos(input: INodeInputSlot): Point {
return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
return calculateInputSlotPosFromSlot(this._getSlotPositionContext(), input)
}
/**
@@ -3916,13 +3920,13 @@ export class LGraphNode
*/
drawCollapsedSlots(ctx: CanvasRenderingContext2D): void {
// Render the first connected slot only.
for (const slot of this.#concreteInputs) {
for (const slot of this._concreteInputs) {
if (slot.link != null) {
slot.drawCollapsed(ctx)
break
}
}
for (const slot of this.#concreteOutputs) {
for (const slot of this._concreteOutputs) {
if (slot.links?.length) {
slot.drawCollapsed(ctx)
break
@@ -3934,7 +3938,7 @@ export class LGraphNode
return [...this.inputs, ...this.outputs]
}
#measureSlot(
private _measureSlot(
slot: NodeInputSlot | NodeOutputSlot,
slotIndex: number,
isInput: boolean
@@ -3951,27 +3955,27 @@ export class LGraphNode
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
#measureSlots(): ReadOnlyRect | null {
private _measureSlots(): ReadOnlyRect | null {
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {
for (const [slotIndex, slot] of this._concreteInputs.entries()) {
// Unrecognized nodes (Nodes with error) has inputs but no widgets. Treat
// converted inputs as normal inputs.
/** Widget input slots are handled in {@link layoutWidgetInputSlots} */
if (this.widgets?.length && isWidgetInputSlot(slot)) continue
this.#measureSlot(slot, slotIndex, true)
this._measureSlot(slot, slotIndex, true)
slots.push(slot)
}
for (const [slotIndex, slot] of this.#concreteOutputs.entries()) {
this.#measureSlot(slot, slotIndex, false)
for (const [slotIndex, slot] of this._concreteOutputs.entries()) {
this._measureSlot(slot, slotIndex, false)
slots.push(slot)
}
return slots.length ? createBounds(slots, 0) : null
}
#getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
private _getMouseOverSlot(slot: INodeSlot): INodeSlot | null {
const isInput = isINodeInputSlot(slot)
const mouseOverId = this.mouseOver?.[isInput ? 'inputId' : 'outputId'] ?? -1
if (mouseOverId === -1) {
@@ -3980,11 +3984,11 @@ export class LGraphNode
return isInput ? this.inputs[mouseOverId] : this.outputs[mouseOverId]
}
#isMouseOverSlot(slot: INodeSlot): boolean {
return this.#getMouseOverSlot(slot) === slot
private _isMouseOverSlot(slot: INodeSlot): boolean {
return this._getMouseOverSlot(slot) === slot
}
#isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
private _isMouseOverWidget(widget: IBaseWidget | undefined): boolean {
if (!widget) return false
return this.mouseOver?.overWidget === widget
}
@@ -4016,9 +4020,9 @@ export class LGraphNode
ctx: CanvasRenderingContext2D,
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
) {
for (const slot of [...this.#concreteInputs, ...this.#concreteOutputs]) {
for (const slot of [...this._concreteInputs, ...this._concreteOutputs]) {
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
const isMouseOverSlot = this.#isMouseOverSlot(slot)
const isMouseOverSlot = this._isMouseOverSlot(slot)
// change opacity of incompatible slots when dragging a connection
const isValid = !fromSlot || isValidTarget
@@ -4033,7 +4037,7 @@ export class LGraphNode
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this.#isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
@@ -4054,7 +4058,7 @@ export class LGraphNode
* - {@link IBaseWidget.y}
* @param widgetStartY The y-coordinate of the first widget
*/
#arrangeWidgets(widgetStartY: number): void {
private _arrangeWidgets(widgetStartY: number): void {
if (!this.widgets || !this.widgets.length) return
const bodyHeight = this.bodyHeight
@@ -4132,7 +4136,7 @@ export class LGraphNode
/**
* Arranges the layout of the node's widget input slots.
*/
#arrangeWidgetInputSlots(): void {
private _arrangeWidgetInputSlots(): void {
if (!this.widgets) return
const slotByWidgetName = new Map<
@@ -4154,10 +4158,10 @@ export class LGraphNode
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this.#concreteInputs[slot.index]
const actualSlot = this._concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this.#measureSlot(actualSlot, slot.index, true)
this._measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
@@ -4165,7 +4169,7 @@ export class LGraphNode
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
}
}
}
@@ -4178,10 +4182,10 @@ export class LGraphNode
* have been removed from the ecosystem.
*/
_setConcreteSlots(): void {
this.#concreteInputs = this.inputs.map((slot) =>
this._concreteInputs = this.inputs.map((slot) =>
toClass(NodeInputSlot, slot, this)
)
this.#concreteOutputs = this.outputs.map((slot) =>
this._concreteOutputs = this.outputs.map((slot) =>
toClass(NodeOutputSlot, slot, this)
)
}
@@ -4190,12 +4194,12 @@ export class LGraphNode
* Arranges node elements in preparation for rendering (slots & widgets).
*/
arrange(): void {
const slotsBounds = this.#measureSlots()
const slotsBounds = this._measureSlots()
const widgetStartY = slotsBounds
? slotsBounds[1] + slotsBounds[3] - this.pos[1]
: 0
this.#arrangeWidgets(widgetStartY)
this.#arrangeWidgetInputSlots()
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
}
/**