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:
Johnpaul
2025-10-09 01:36:29 +01:00
parent 5b37fc59e7
commit 0685a1da3c
4 changed files with 378 additions and 1 deletions

View File

@@ -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)

View 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()

View File

@@ -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 },

View 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' })
})
})
})