Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/composables/useContextMenuTranslation.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import { st, te } from '@/i18n'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type {
IContextMenuOptions,
IContextMenuValue,
INodeInputSlot,
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)
Expand Down
115 changes: 115 additions & 0 deletions src/lib/litegraph/src/contextMenuCompat.ts
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 19 additions & 1 deletion src/services/extensionService.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 },
Expand Down
65 changes: 65 additions & 0 deletions tests-ui/tests/extensions/contextMenuExtensionName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'

import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'

/**
* Test that demonstrates the extension name appearing in deprecation warnings
*/
describe('Context Menu Extension Name in Warnings', () => {
it('should include extension name in deprecation warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')

// Simulate what happens during extension setup
legacyMenuCompat.setCurrentExtension('MyCustomExtension')

// Extension monkey-patches the method
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'My Custom Menu Item', callback: () => {} })
return items
}

// Clear extension (happens after setup completes)
legacyMenuCompat.setCurrentExtension(null)

// Verify the warning includes the extension name
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]

expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getCanvasMenuOptions')
expect(warningMessage).toContain('"MyCustomExtension"')

vi.restoreAllMocks()
})

it('should not include extension name if not set', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')

// Extension monkey-patches without setting current extension
const original = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'My Node Menu Item', callback: () => {} })
return items
}

// Verify the warning does NOT include extension info
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]

expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getNodeMenuOptions')
expect(warningMessage).not.toContain('Extension:')

vi.restoreAllMocks()
})
})
Loading
Loading