mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 15:54:09 +00:00
feat(contextMenu): add legacy compatibility layer for monkey-patched extensions
Adds a compatibility layer that detects and supports legacy extensions using the monkey-patching pattern, while warning developers about the deprecated approach. **Features:** - Automatic detection of monkey-patched context menu methods - Console warnings with extension name for deprecated patterns - Extraction and integration of legacy menu items - Extension tracking during setup for accurate warnings **Files:** - `src/lib/litegraph/src/contextMenuCompat.ts`: Core compatibility logic - `src/services/extensionService.ts`: Extension name tracking - `src/composables/useContextMenuTranslation.ts`: Integration layer - Comprehensive test coverage Depends on PR #5977 (context menu extension API)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { st, te } from '@/i18n'
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import type {
|
||||
IContextMenuOptions,
|
||||
IContextMenuValue,
|
||||
@@ -6,18 +7,42 @@ import type {
|
||||
IWidget
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Add translation for litegraph context menu.
|
||||
*/
|
||||
export const useContextMenuTranslation = () => {
|
||||
// Install compatibility layer BEFORE any extensions load
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
const f = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
const getCanvasCenterMenuOptions = function (
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof f>
|
||||
) {
|
||||
const res = f.apply(this, args) as ReturnType<typeof f>
|
||||
|
||||
// Add items from new extension API
|
||||
const newApiItems = app.collectCanvasMenuItems(this)
|
||||
for (const item of newApiItems) {
|
||||
// @ts-expect-error - Generic types differ but runtime compatibility is ensured
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
// Add legacy monkey-patched items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
this,
|
||||
...args
|
||||
)
|
||||
for (const item of legacyItems) {
|
||||
// @ts-expect-error - Generic types differ but runtime compatibility is ensured
|
||||
res.push(item)
|
||||
}
|
||||
|
||||
// Translate all items
|
||||
for (const item of res) {
|
||||
if (item?.content) {
|
||||
item.content = st(`contextMenu.${item.content}`, item.content)
|
||||
|
||||
115
src/lib/litegraph/src/contextMenuCompat.ts
Normal file
115
src/lib/litegraph/src/contextMenuCompat.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { IContextMenuValue } from './interfaces'
|
||||
|
||||
/**
|
||||
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
|
||||
* To disable legacy support, set ENABLE_LEGACY_SUPPORT = false
|
||||
*/
|
||||
const ENABLE_LEGACY_SUPPORT = true
|
||||
|
||||
type AnyFunction = (...args: any[]) => any
|
||||
|
||||
class LegacyMenuCompat {
|
||||
private originalMethods = new Map<string, AnyFunction>()
|
||||
private hasWarned = new Set<string>()
|
||||
private currentExtension: string | null = null
|
||||
|
||||
/**
|
||||
* Set the name of the extension that is currently being set up.
|
||||
* This allows us to track which extension is monkey-patching.
|
||||
* @param extensionName The name of the extension
|
||||
*/
|
||||
setCurrentExtension(extensionName: string | null) {
|
||||
this.currentExtension = extensionName
|
||||
}
|
||||
|
||||
/**
|
||||
* Install compatibility layer to detect monkey-patching
|
||||
* @param prototype The prototype to install on (e.g., LGraphCanvas.prototype)
|
||||
* @param methodName The method name to track (e.g., 'getCanvasMenuOptions')
|
||||
*/
|
||||
install(prototype: any, methodName: string) {
|
||||
if (!ENABLE_LEGACY_SUPPORT) return
|
||||
|
||||
// Store original
|
||||
const originalMethod = prototype[methodName]
|
||||
this.originalMethods.set(methodName, originalMethod)
|
||||
|
||||
// Wrap with getter/setter to detect patches
|
||||
let currentImpl = originalMethod
|
||||
|
||||
Object.defineProperty(prototype, methodName, {
|
||||
get() {
|
||||
return currentImpl
|
||||
},
|
||||
set: (newImpl: AnyFunction) => {
|
||||
// Log once per unique function
|
||||
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
|
||||
if (!this.hasWarned.has(fnKey)) {
|
||||
this.hasWarned.add(fnKey)
|
||||
|
||||
const extensionInfo = this.currentExtension
|
||||
? ` (Extension: "${this.currentExtension}")`
|
||||
: ''
|
||||
|
||||
console.warn(
|
||||
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated.${extensionInfo}\n` +
|
||||
`Please use the new context menu API instead.\n\n` +
|
||||
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
)
|
||||
}
|
||||
currentImpl = newImpl
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract items that were added by legacy monkey patches
|
||||
* @param methodName The method name that was monkey-patched
|
||||
* @param context The context to call methods with (e.g., canvas instance)
|
||||
* @param args Arguments to pass to the methods
|
||||
* @returns Array of menu items added by monkey patches
|
||||
*/
|
||||
extractLegacyItems(
|
||||
methodName: string,
|
||||
context: any,
|
||||
...args: any[]
|
||||
): IContextMenuValue[] {
|
||||
if (!ENABLE_LEGACY_SUPPORT) return []
|
||||
|
||||
const originalMethod = this.originalMethods.get(methodName)
|
||||
if (!originalMethod) return []
|
||||
|
||||
try {
|
||||
// Get baseline from original
|
||||
const originalItems = originalMethod.apply(context, args) as
|
||||
| IContextMenuValue[]
|
||||
| undefined
|
||||
if (!originalItems) return []
|
||||
|
||||
// Get current method (potentially patched)
|
||||
const currentMethod = context.constructor.prototype[methodName]
|
||||
if (!currentMethod || currentMethod === originalMethod) return []
|
||||
|
||||
// Get items from patched method
|
||||
const patchedItems = currentMethod.apply(context, args) as
|
||||
| IContextMenuValue[]
|
||||
| undefined
|
||||
if (!patchedItems) return []
|
||||
|
||||
// Return items that were added (simple slice approach)
|
||||
if (patchedItems.length > originalItems.length) {
|
||||
return patchedItems.slice(originalItems.length)
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (e) {
|
||||
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const legacyMenuCompat = new LegacyMenuCompat()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -122,8 +123,25 @@ export const useExtensionService = () => {
|
||||
extensionStore.enabledExtensions.map(async (ext) => {
|
||||
if (method in ext) {
|
||||
try {
|
||||
return await ext[method](...args, app)
|
||||
// Set current extension name for legacy compatibility tracking
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(ext.name)
|
||||
}
|
||||
|
||||
const result = await ext[method](...args, app)
|
||||
|
||||
// Clear current extension after setup
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// Clear current extension on error too
|
||||
if (method === 'setup') {
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Error calling extension '${ext.name}' method '${method}'`,
|
||||
{ error },
|
||||
|
||||
219
tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Normal file
219
tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
describe('contextMenuCompat', () => {
|
||||
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
let mockCanvas: LGraphCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original method
|
||||
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
|
||||
// Create mock canvas
|
||||
mockCanvas = {
|
||||
constructor: {
|
||||
prototype: LGraphCanvas.prototype
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Clear console warnings
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('install', () => {
|
||||
it('should install compatibility layer on prototype', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
// The method should still be callable
|
||||
expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe(
|
||||
'function'
|
||||
)
|
||||
})
|
||||
|
||||
it('should detect monkey patches and warn', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
const warnSpy = vi.spyOn(console, 'warn')
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
// Set current extension before monkey-patching
|
||||
legacyMenuCompat.setCurrentExtension('Test Extension')
|
||||
|
||||
// Simulate extension monkey-patching
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'Custom Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Should have logged a warning with extension name
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED]'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Test Extension"'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
// Clear extension
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
})
|
||||
|
||||
it('should only warn once per unique function', () => {
|
||||
const methodName = 'getCanvasMenuOptions'
|
||||
const warnSpy = vi.spyOn(console, 'warn')
|
||||
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
|
||||
|
||||
const patchFunction = function (this: LGraphCanvas, ...args: any[]) {
|
||||
const items = (originalGetCanvasMenuOptions as any).apply(this, args)
|
||||
items.push({ content: 'Custom', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Patch twice with same function
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
|
||||
|
||||
// Should only warn once
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractLegacyItems', () => {
|
||||
beforeEach(() => {
|
||||
// Setup a mock original method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [
|
||||
{ content: 'Item 1', callback: () => {} },
|
||||
{ content: 'Item 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
|
||||
// Install compatibility layer
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
})
|
||||
|
||||
it('should extract items added by monkey patches', () => {
|
||||
// Monkey-patch to add items
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'Custom Item 1', callback: () => {} })
|
||||
items.push({ content: 'Custom Item 2', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Extract legacy items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(2)
|
||||
expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' })
|
||||
expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' })
|
||||
})
|
||||
|
||||
it('should return empty array when no items added', () => {
|
||||
// No monkey-patching, so no extra items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return empty array when patched method returns same count', () => {
|
||||
// Monkey-patch that replaces items but keeps same count
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [
|
||||
{ content: 'Replaced 1', callback: () => {} },
|
||||
{ content: 'Replaced 2', callback: () => {} }
|
||||
]
|
||||
}
|
||||
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Monkey-patch that throws error
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
throw new Error('Test error')
|
||||
}
|
||||
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
expect(legacyItems).toHaveLength(0)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to extract legacy items'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('should work with multiple extensions patching', () => {
|
||||
// Setup base method
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [{ content: 'Base Item', callback: () => {} }]
|
||||
}
|
||||
|
||||
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
|
||||
|
||||
// First extension patches
|
||||
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original1 as any).apply(this, args)
|
||||
items.push({ content: 'Extension 1 Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Second extension patches
|
||||
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original2 as any).apply(this, args)
|
||||
items.push({ content: 'Extension 2 Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Extract legacy items
|
||||
const legacyItems = legacyMenuCompat.extractLegacyItems(
|
||||
'getCanvasMenuOptions',
|
||||
mockCanvas
|
||||
)
|
||||
|
||||
// Should extract both items added by extensions
|
||||
expect(legacyItems).toHaveLength(2)
|
||||
expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' })
|
||||
expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user