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

@@ -6,7 +6,7 @@ type DialogAction<T> = string | { value?: T; text: string }
export class ComfyAsyncDialog<
T = string | null
> extends ComfyDialog<HTMLDialogElement> {
#resolve: (value: T | null) => void = () => {}
private _resolve: (value: T | null) => void = () => {}
constructor(actions?: Array<DialogAction<T>>) {
super(
@@ -30,7 +30,7 @@ export class ComfyAsyncDialog<
super.show(html)
return new Promise((resolve) => {
this.#resolve = resolve
this._resolve = resolve
})
}
@@ -43,12 +43,12 @@ export class ComfyAsyncDialog<
this.element.showModal()
return new Promise((resolve) => {
this.#resolve = resolve
this._resolve = resolve
})
}
override close(result: T | null = null) {
this.#resolve(result)
this._resolve(result)
this.element.close()
super.close()
}

View File

@@ -21,8 +21,8 @@ type ComfyButtonProps = {
}
export class ComfyButton implements ComfyComponent<HTMLElement> {
#over = 0
#popupOpen = false
private _over = 0
private _popupOpen = false
isOver = false
iconElement = $el('i.mdi')
contentElement = $el('span')
@@ -123,7 +123,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
this.element.addEventListener('click', (e) => {
if (this.popup) {
// we are either a touch device or triggered by click not hover
if (!this.#over) {
if (!this._over) {
this.popup.toggle()
}
}
@@ -157,7 +157,7 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
internalClasses.push('disabled')
}
if (this.popup) {
if (this.#popupOpen) {
if (this._popupOpen) {
internalClasses.push('popup-open')
} else {
internalClasses.push('popup-closed')
@@ -172,16 +172,16 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
if (mode === 'hover') {
for (const el of [this.element, this.popup.element]) {
el.addEventListener('mouseenter', () => {
this.popup.open = !!++this.#over
this.popup.open = !!++this._over
})
el.addEventListener('mouseleave', () => {
this.popup.open = !!--this.#over
this.popup.open = !!--this._over
})
}
}
popup.addEventListener('change', () => {
this.#popupOpen = popup.open
this._popupOpen = popup.open
this.updateClasses()
})

View File

@@ -54,9 +54,9 @@ export class ComfyPopup extends EventTarget {
this.open = prop(this, 'open', false, (v, o) => {
if (v === o) return
if (v) {
this.#show()
this._show()
} else {
this.#hide()
this._hide()
}
})
}
@@ -65,24 +65,24 @@ export class ComfyPopup extends EventTarget {
this.open = !this.open
}
#hide() {
private _hide() {
this.element.classList.remove('open')
window.removeEventListener('resize', this.update)
window.removeEventListener('click', this.#clickHandler, { capture: true })
window.removeEventListener('keydown', this.#escHandler, { capture: true })
window.removeEventListener('click', this._clickHandler, { capture: true })
window.removeEventListener('keydown', this._escHandler, { capture: true })
this.dispatchEvent(new CustomEvent('close'))
this.dispatchEvent(new CustomEvent('change'))
}
#show() {
private _show() {
this.element.classList.add('open')
this.update()
window.addEventListener('resize', this.update)
window.addEventListener('click', this.#clickHandler, { capture: true })
window.addEventListener('click', this._clickHandler, { capture: true })
if (this.closeOnEscape) {
window.addEventListener('keydown', this.#escHandler, { capture: true })
window.addEventListener('keydown', this._escHandler, { capture: true })
}
this.dispatchEvent(new CustomEvent('open'))
@@ -90,7 +90,7 @@ export class ComfyPopup extends EventTarget {
}
// @ts-expect-error fixme ts strict error
#escHandler = (e) => {
private _escHandler = (e) => {
if (e.key === 'Escape') {
this.open = false
e.preventDefault()
@@ -99,7 +99,7 @@ export class ComfyPopup extends EventTarget {
}
// @ts-expect-error fixme ts strict error
#clickHandler = (e) => {
private _clickHandler = (e) => {
/** @type {any} */
const target = e.target
if (

View File

@@ -5,11 +5,11 @@ export class ComfyDialog<
> extends EventTarget {
element: T
textElement!: HTMLElement
#buttons: HTMLButtonElement[] | null
private _buttons: HTMLButtonElement[] | null
constructor(type = 'div', buttons: HTMLButtonElement[] | null = null) {
super()
this.#buttons = buttons
this._buttons = buttons
this.element = $el(type + '.comfy-modal', { parent: document.body }, [
$el('div.comfy-modal-content', [
$el('p', { $: (p) => (this.textElement = p) }),
@@ -20,7 +20,7 @@ export class ComfyDialog<
createButtons() {
return (
this.#buttons ?? [
this._buttons ?? [
$el('button', {
type: 'button',
textContent: 'Close',