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

@@ -294,7 +294,7 @@ export class PromptExecutionError extends Error {
}
export class ComfyApi extends EventTarget {
#registered = new Set()
private _registered = new Set()
api_host: string
api_base: string
/**
@@ -451,7 +451,7 @@ export class ComfyApi extends EventTarget {
) {
// Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe.
super.addEventListener(type, callback as EventListener, options)
this.#registered.add(type)
this._registered.add(type)
}
override removeEventListener<TEvent extends keyof ApiEvents>(
@@ -492,7 +492,7 @@ export class ComfyApi extends EventTarget {
/**
* Poll status for colab and other things that don't support websockets.
*/
#pollQueue() {
private _pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi('/prompt')
@@ -568,7 +568,7 @@ export class ComfyApi extends EventTarget {
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this.#pollQueue()
this._pollQueue()
}
})
@@ -691,7 +691,7 @@ export class ComfyApi extends EventTarget {
)
break
default:
if (this.#registered.has(msg.type)) {
if (this._registered.has(msg.type)) {
// Fallback for custom types - calls super direct.
super.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
@@ -956,7 +956,7 @@ export class ComfyApi extends EventTarget {
* @param {*} type The endpoint to post to
* @param {*} body Optional POST data
*/
async #postItem(type: string, body?: Record<string, unknown>) {
private async _postItem(type: string, body?: Record<string, unknown>) {
try {
await this.fetchApi('/' + type, {
method: 'POST',
@@ -976,7 +976,7 @@ export class ComfyApi extends EventTarget {
* @param {number} id The id of the item to delete
*/
async deleteItem(type: string, id: string) {
await this.#postItem(type, { delete: [id] })
await this._postItem(type, { delete: [id] })
}
/**
@@ -984,7 +984,7 @@ export class ComfyApi extends EventTarget {
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type: string) {
await this.#postItem(type, { clear: true })
await this._postItem(type, { clear: true })
}
/**
@@ -993,7 +993,7 @@ export class ComfyApi extends EventTarget {
* @param {string | null} [runningPromptId] Optional Running Prompt ID to interrupt
*/
async interrupt(runningPromptId: string | null) {
await this.#postItem(
await this._postItem(
'interrupt',
runningPromptId ? { prompt_id: runningPromptId } : undefined
)

View File

@@ -241,17 +241,17 @@ function dragElement(dragEl): () => void {
}
class ComfyList {
#type
#text
#reverse
private _type
private _text
private _reverse
element: HTMLDivElement
button?: HTMLButtonElement
// @ts-expect-error fixme ts strict error
constructor(text, type?, reverse?) {
this.#text = text
this.#type = type || text.toLowerCase()
this.#reverse = reverse || false
this._text = text
this._type = type || text.toLowerCase()
this._reverse = reverse || false
this.element = $el('div.comfy-list') as HTMLDivElement
this.element.style.display = 'none'
}
@@ -261,7 +261,7 @@ class ComfyList {
}
async load() {
const items = await api.getItems(this.#type)
const items = await api.getItems(this._type)
this.element.replaceChildren(
...Object.keys(items).flatMap((section) => [
$el('h4', {
@@ -269,12 +269,12 @@ class ComfyList {
}),
$el('div.comfy-list-items', [
// @ts-expect-error fixme ts strict error
...(this.#reverse ? items[section].reverse() : items[section]).map(
...(this._reverse ? items[section].reverse() : items[section]).map(
(item: LegacyQueueItem) => {
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
const removeAction = item.remove ?? {
name: 'Delete',
cb: () => api.deleteItem(this.#type, item.prompt[1])
cb: () => api.deleteItem(this._type, item.prompt[1])
}
return $el('div', { textContent: item.prompt[0] + ': ' }, [
$el('button', {
@@ -311,9 +311,9 @@ class ComfyList {
]),
$el('div.comfy-list-actions', [
$el('button', {
textContent: 'Clear ' + this.#text,
textContent: 'Clear ' + this._text,
onclick: async () => {
await api.clearItems(this.#type)
await api.clearItems(this._type)
await this.load()
}
}),
@@ -339,7 +339,7 @@ class ComfyList {
hide() {
this.element.style.display = 'none'
// @ts-expect-error fixme ts strict error
this.button.textContent = 'View ' + this.#text
this.button.textContent = 'View ' + this._text
}
toggle() {

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',