Compare commits

...

15 Commits

Author SHA1 Message Date
github-actions
0c20157537 [automated] Update test expectations 2026-07-10 12:41:46 +00:00
pythongosssss
83ea8d4e8a fix: mark live warnings seen reliably while panel is open
markAllSeen inside a watchEffect mutates its own dependency; the
recursion guard drops the subscription after a mount-time clear, so
warnings arriving while the panel was open stayed unseen. Use an
immediate watch instead and pin the regression in the component test
2026-07-10 05:39:48 -07:00
pythongosssss
e5f242ed96 fix: migrate onBeforeChange deprecation test to registry store
The test asserted via the removed LiteGraph.onDeprecationWarning
listener array; assert via the deprecation warnings store instead
2026-07-10 04:50:40 -07:00
pythongosssss
20156460a6 fix: migrate LGraph.onBeforeChange deprecation to registry
Added on main via the old litegraph feedback module, which this branch
removes; also drop the now-unused warnDeprecated import in ui.ts
2026-07-10 04:18:11 -07:00
pythongosssss
0c874dcd2f fix: address CodeRabbit feedback on deprecation warnings panel
- Prune stale extension filter selections when their options disappear
- Compare warning sources as sets instead of comparator-less sort
2026-07-10 04:11:45 -07:00
pythongosssss
0628aed16c fix: address review feedback on deprecation warnings
- Gate server-log backfill and __reportDeprecation seam on Comfy.DevMode
- Mark warnings seen as they arrive while the panel is open
- Dedupe and cap the pre-Pinia buffer; drain with occurrence counts
- Add alwaysRepeatWarnings no-op shim with registry entry
- Return shared inert array from onDeprecationWarning getter
2026-07-10 04:11:45 -07:00
pythongosssss
e6e0c0bdd4 tidy: add tests, use popover entries 2026-07-10 04:11:24 -07:00
comfydesigner
09e03be1d4 polish: deprecation warnings panel UI tweaks (#12949)
## Summary

Visual polish on the deprecation warnings sidebar panel, plus a
per-warning clear action. Stacked on top of this PR.

## Changes

- **What**:
- Card: uniform `gap-2`, `rounded-md`; kebab menu moved to absolute
top-right so wrapped messages stay top-aligned
- Right cluster: `text-sm` timestamp; hand-rolled count span replaced
with `<Badge severity="contrast" variant="circle">` (with `class`
overrides so `9+` fits)
- Fix box: `rounded-md`, `p-3`; suggestion uses inline text flow (single
line-box) so the `Fix:` label baseline matches the body; migration link
uses `<Button as="a" variant="link">`
  - Clear all (top): `muted-textonly`, `text-sm`, icon removed
- Per-warning kebab `Popover` with single "Clear" action backed by a new
`store.remove(key)`
- Extension `MultiSelect`: `size="lg"` (only external path to `text-sm`
label)

## Review Focus

- **`#tool-buttons` is hover-revealed** by `SidebarTabTemplate` ([line
23](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/sidebar/tabs/SidebarTabTemplate.vue#L23)).
Clear all therefore vanishes when not hovering the panel — possibly not
what we want here, but out of scope for this PR. Either move the action
out of `#tool-buttons` or accept the hover-reveal.
- The count `Badge` needs a wider count-style variant; the current
`variant="circle"` is fixed `size-3.5`, so we override via `class`.
Worth a follow-up to add it to `Badge`.
- `MultiSelect` has no label-size prop, so `size="lg"` is the only way
to get `text-sm`. Worth exposing one.

## Screenshots

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
2026-07-10 04:11:24 -07:00
pythongosssss
c1dae8bad5 fix tests, expose report depercation for testing 2026-07-10 04:11:24 -07:00
pythongosssss
e149ca0dd2 clear filters on clear warnings 2026-07-10 04:10:56 -07:00
pythongosssss
ce103c8e46 - unify app/lg warnings into common typed registry with unique ids instead of raw strings, drop bridge
- add ssot for deprecations allowing autogenerated deprecation docs
- refactor warnings to add additional extension source and details, update UI and add extension filter
- fix MultiSelect dismiss via useDismissableOverlay
- deprecate LiteGraph.onDeprecationWarning
- add commandStore.unregisterCommand and call on sidebar tab unregister
- backfill panel from server logs on load
2026-07-10 04:10:56 -07:00
pythongosssss
d08c0e02da match src constant names 2026-07-10 04:09:16 -07:00
pythongosssss
bf78f8718d rabbit: dedupe concurrent requests, fix missing date string 2026-07-10 04:09:16 -07:00
pythongosssss
34f08ac479 remove export 2026-07-10 04:09:16 -07:00
pythongosssss
315fd0bc39 feat: add dev mode deprecation panel
- add deprecation warning panel with deduped counts
- add store, litegraph bridge and seed with server logs
- update existing deprecation warnings and logs
2026-07-10 04:09:16 -07:00
60 changed files with 2623 additions and 223 deletions

View File

@@ -7,6 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator
public readonly badge: Locator
constructor(
public readonly page: Page,
@@ -16,6 +17,7 @@ class SidebarTab {
this.selectedTabButton = page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
this.badge = this.tabButton.locator('.sidebar-icon-badge')
}
async open() {
@@ -25,7 +27,7 @@ class SidebarTab {
await this.tabButton.click()
}
async close() {
if (!this.tabButton.isVisible()) {
if (!(await this.tabButton.isVisible())) {
return
}
await this.tabButton.click()
@@ -54,7 +56,7 @@ export class NodeLibrarySidebarTab extends SidebarTab {
}
override async close() {
if (!this.tabButton.isVisible()) {
if (!(await this.tabButton.isVisible())) {
return
}
@@ -464,3 +466,31 @@ export class AssetsSidebarTab extends SidebarTab {
}
}
}
export class DeprecationWarningsSidebarTab extends SidebarTab {
public readonly panel: Locator
public readonly list: Locator
public readonly clearAllButton: Locator
public readonly emptyState: Locator
constructor(public override readonly page: Page) {
super(page, 'deprecation-warnings')
this.panel = page.getByTestId('deprecation-warnings-sidebar')
this.list = page.getByTestId('deprecation-warnings-list')
this.clearAllButton = this.panel.getByRole('button', { name: 'Clear all' })
this.emptyState = this.panel.getByText(
'No deprecation warnings reported in this session.'
)
}
rowFor(message: string): Locator {
return this.list.locator('li').filter({ hasText: message })
}
async emitDeprecation(message: string): Promise<void> {
await this.page.waitForFunction(() => !!window.__reportDeprecation)
await this.page.evaluate((m: string) => {
window.__reportDeprecation!(m)
}, message)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,108 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { DeprecationWarningsSidebarTab } from '@e2e/fixtures/components/SidebarTab'
test.describe('Deprecation warnings sidebar', { tag: '@ui' }, () => {
test.describe('with DevMode disabled', () => {
test.use({ initialSettings: { 'Comfy.DevMode': false } })
test('does not render the sidebar icon', async ({ comfyPage }) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toHaveCount(0)
expect(
await comfyPage.page.evaluate(() => !!window.__reportDeprecation)
).toBe(false)
})
})
test.describe('with DevMode enabled', () => {
test.use({ initialSettings: { 'Comfy.DevMode': true } })
test('records a new deprecation, shows it in the panel, dedupes repeats', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toBeVisible()
const uniqueMessage = `e2e-${Date.now()} is deprecated.`
await tab.emitDeprecation(uniqueMessage)
await tab.open()
await expect(tab.panel).toBeVisible()
const row = tab.rowFor(uniqueMessage)
await expect(row).toBeVisible()
await tab.emitDeprecation(uniqueMessage)
await expect(row.getByTestId('deprecation-warning-badge')).toHaveText('2')
await expect(tab.rowFor(uniqueMessage)).toHaveCount(1)
})
test('opening the panel clears the unseen badge', async ({ comfyPage }) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await expect(tab.tabButton).toBeVisible()
// Mark any pre-existing warnings as seen.
await tab.open()
await expect(tab.panel).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
await expect(tab.badge).toHaveCount(0)
await tab.emitDeprecation(`e2e-badge-${Date.now()} is deprecated.`)
await expect(tab.badge).toHaveText('1')
await tab.open()
await expect(tab.badge).toHaveCount(0)
// Warnings arriving while the panel is open must not resurface the badge.
const liveMessage = `e2e-badge-live-${Date.now()} is deprecated.`
await tab.emitDeprecation(liveMessage)
await expect(tab.rowFor(liveMessage)).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
await expect(tab.badge).toHaveCount(0)
})
test('clear all empties the panel and shows the empty state', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await tab.open()
await expect(tab.panel).toBeVisible()
const uniqueMessage = `e2e-clearAll-${Date.now()} is deprecated.`
await tab.emitDeprecation(uniqueMessage)
await expect(tab.rowFor(uniqueMessage)).toBeVisible()
// Tool-buttons slot is hover/focus-gated; hover the panel to reveal it.
await tab.panel.hover()
await tab.clearAllButton.click()
await expect(tab.list).toHaveCount(0)
await expect(tab.emptyState).toBeVisible()
await expect(tab.clearAllButton).toHaveCount(0)
})
test('clamps the toolbar badge at "9+" when many unseen warnings pile up', async ({
comfyPage
}) => {
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
await tab.open()
await expect(tab.panel).toBeVisible()
await tab.close()
await expect(tab.panel).toBeHidden()
const stamp = Date.now()
for (let i = 0; i < 12; i++) {
await tab.emitDeprecation(`e2e-${stamp}-${i} is deprecated.`)
}
await expect(tab.badge).toHaveText('9+')
})
})
})

View File

@@ -42,6 +42,9 @@ declare global {
__capturedMessages?: CapturedMessages
__appReadiness?: AppReadiness
/** For use in tests to inject a synthetic deprecation warning. */
__reportDeprecation?: (message: string) => void
/**
* WebSocket store used by test fixtures for mocking WebSocket connections.
* @see browser_tests/fixtures/ws.ts

View File

@@ -31,6 +31,7 @@
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"gen:deprecations": "tsx scripts/generate-deprecations-doc.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
import { renderDeprecationsMdx } from './generate-deprecations-doc'
const SAMPLE: Record<string, DeprecationEntry> = {
'b.thing': {
source: 'beta',
message: 'thing is deprecated.',
suggestion: 'Use otherThing.',
since: '1.0',
removeBy: '2.0',
docsUrl: 'https://docs.example/thing'
},
'a.first': {
source: 'alpha',
message: 'first is gone.'
},
'a.second': {
source: 'alpha',
message: 'second is gone | really.'
}
}
describe('renderDeprecationsMdx', () => {
const mdx = renderDeprecationsMdx(SAMPLE)
it('emits Mintlify frontmatter', () => {
expect(mdx.startsWith('---\ntitle: "Deprecations"')).toBe(true)
})
it('includes an intro explaining the deprecation policy before the tables', () => {
expect(mdx).toContain('Whilst we try to keep the frontend API stable')
expect(mdx.indexOf('Whilst we try')).toBeLessThan(mdx.indexOf('## alpha'))
})
it('documents the dev-mode-gated Deprecation Warnings panel', () => {
expect(mdx).toContain('## Deprecation Warnings panel')
expect(mdx).toContain('dev mode')
})
it('groups entries by source, sorted alphabetically', () => {
expect(mdx.indexOf('## alpha')).toBeLessThan(mdx.indexOf('## beta'))
})
it('renders a row per entry with replacement, version and docs columns', () => {
expect(mdx).toContain(
'| thing is deprecated. | Use otherThing. | 1.0 | 2.0 | [Guide](https://docs.example/thing) |'
)
})
it('uses a dash for missing optional fields', () => {
expect(mdx).toContain('| first is gone. | - | - | - | - |')
})
it('escapes pipe characters so the table is not broken', () => {
expect(mdx).toContain('second is gone \\| really.')
})
it('omits optional columns when no entry anywhere has a value', () => {
const minimal = renderDeprecationsMdx({
'a.first': { source: 'alpha', message: 'first is gone.' },
'a.second': { source: 'alpha', message: 'second is gone.' }
})
expect(minimal).toContain('| Deprecated | Replacement |')
expect(minimal).not.toContain('Since')
expect(minimal).not.toContain('Removed in')
expect(minimal).not.toContain('Docs')
})
it('keeps a column when at least one entry in any table has a value', () => {
const mixed = renderDeprecationsMdx({
'a.first': { source: 'alpha', message: 'first is gone.' },
'b.second': { source: 'beta', message: 'second is gone.', since: '1.0' }
})
expect(mixed).toContain('| Deprecated | Replacement | Since |')
expect(mixed).not.toContain('Removed in')
})
})

View File

@@ -0,0 +1,119 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
import { DEPRECATIONS } from '../src/platform/dev/deprecations'
const OUTPUT_PATH = './docs/deprecations.mdx'
const FRONTMATTER = `---
title: "Deprecations"
description: "Deprecated frontend APIs and their replacements."
---`
const INTRO = `Whilst we try to keep the frontend API stable and backwards compatible, there are times where functionality must change - to fix bugs, improve performance, or make room for new features.
When that happens we deprecate the old API rather than removing it outright: it keeps working until the removal date, but logs a warning pointing you at the replacement. This page lists the currently deprecated APIs so you can migrate your custom nodes and extensions ahead of their removal.
## Deprecation Warnings panel
To see which deprecated APIs are actually being used in your current session, enable the **"Enable dev mode options"** setting (Settings → search "dev mode"). A **Deprecation Warnings** panel then appears in the main sidebar, listing each warning reported this session - with the suggested replacement and how many times it occurred.
Alternatively, every deprecation is logged to the browser DevTools console as it occurs, prefixed with a \`[DEPRECATED]\` tag.`
interface Column {
header: string
cell: (entry: DeprecationEntry) => string | undefined
}
const REQUIRED_COLUMNS: Column[] = [
{ header: 'Deprecated', cell: (entry) => entry.message },
{ header: 'Replacement', cell: (entry) => entry.suggestion }
]
const OPTIONAL_COLUMNS: Column[] = [
{ header: 'Since', cell: (entry) => entry.since },
{ header: 'Removed in', cell: (entry) => entry.removeBy },
{
header: 'Docs',
cell: (entry) => (entry.docsUrl ? `[Guide](${entry.docsUrl})` : undefined)
}
]
function escapeCell(value: string | undefined): string {
if (!value) return '-'
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
}
function activeColumns(entries: DeprecationEntry[]): Column[] {
return [
...REQUIRED_COLUMNS,
...OPTIONAL_COLUMNS.filter((column) =>
entries.some((entry) => column.cell(entry))
)
]
}
function groupBySource(
deprecations: Record<string, DeprecationEntry>
): Map<string, DeprecationEntry[]> {
const groups = new Map<string, DeprecationEntry[]>()
for (const entry of Object.values(deprecations)) {
const group = groups.get(entry.source) ?? []
group.push(entry)
groups.set(entry.source, group)
}
return groups
}
function renderRow(cells: string[]): string {
return `| ${cells.join(' | ')} |`
}
function renderSection(
source: string,
entries: DeprecationEntry[],
columns: Column[]
): string {
const rows = entries.map((entry) =>
renderRow(columns.map((column) => escapeCell(column.cell(entry))))
)
return [
`## ${source}`,
'',
renderRow(columns.map((column) => column.header)),
renderRow(columns.map(() => '---')),
...rows
].join('\n')
}
export function renderDeprecationsMdx(
deprecations: Record<string, DeprecationEntry>
): string {
const columns = activeColumns(Object.values(deprecations))
const sections = [...groupBySource(deprecations).entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([source, entries]) => renderSection(source, entries, columns))
return [
FRONTMATTER,
'',
'{/* Generated by scripts/generate-deprecations-doc.ts. Do not edit by hand. */}',
'',
INTRO,
'',
sections.join('\n\n'),
''
].join('\n')
}
function main(): void {
const mdx = renderDeprecationsMdx(DEPRECATIONS)
fs.writeFileSync(path.resolve(OUTPUT_PATH), mdx)
}
const invokedDirectly =
process.argv[1] &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
if (invokedDirectly) main()

View File

@@ -170,6 +170,7 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { electronAPI } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { formatRelativeTime } from '@/utils/relativeTime'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -188,16 +189,6 @@ interface MenuItem {
showExternalIcon?: boolean
}
// Constants
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
WEEK: 7 * 24 * 60 * 60 * 1000,
MONTH: 30 * 24 * 60 * 60 * 1000,
YEAR: 365 * 24 * 60 * 60 * 1000
} as const
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
@@ -491,29 +482,9 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
}
const formatReleaseDate = (dateString?: string): string => {
if (!dateString) return 'date'
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
]
for (const { unit, key } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return t(`g.relativeTime.${key}`, { count: value })
}
}
return t('g.relativeTime.now')
if (!dateString) return t('helpCenter.missingDate')
const diffMs = Math.abs(Date.now() - new Date(dateString).getTime())
return formatRelativeTime(t, diffMs)
}
// Event Handlers

View File

@@ -21,7 +21,7 @@
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton />
<SidebarIcon
v-for="tab in tabs"
v-for="tab in topTabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
@@ -37,6 +37,19 @@
</div>
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
<SidebarIcon
v-for="tab in bottomTabs"
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarLogoutIcon
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
@@ -121,6 +134,12 @@ const isConnected = computed(
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const topTabs = computed(() =>
tabs.value.filter((tab) => tab.placement !== 'bottom')
)
const bottomTabs = computed(() =>
tabs.value.filter((tab) => tab.placement === 'bottom')
)
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**

View File

@@ -0,0 +1,333 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
function renderTab() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
return {
user: userEvent.setup(),
...render(DeprecationWarningsTab, { global: { plugins: [i18n] } })
}
}
describe('DeprecationWarningsTab', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
// Drain warnings buffered during module-load imports.
useDeprecationWarningsStore().clear()
})
afterEach(() => {
cleanup()
})
it('renders the empty state when there are no warnings', () => {
renderTab()
screen.getByText('deprecationWarnings.empty')
expect(screen.queryByTestId('deprecation-warnings-list')).toBeNull()
})
it('renders each warning with its message, suggestion, and source', () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'foo() is deprecated.',
suggestion: 'Use bar() instead.',
source: 'lib'
})
renderTab()
screen.getByText('foo() is deprecated.')
screen.getByText('Use bar() instead.')
screen.getByText('lib')
})
it('surfaces the extension and detail as distinct fields from the message', () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'Use of defaultInput on a required input.',
source: 'nodeDef',
extension: 'custom_nodes.devtools',
detail: 'DevToolsNode.int_input'
})
renderTab()
screen.getByText('Use of defaultInput on a required input.')
screen.getByText('nodeDef')
screen.getByText('custom_nodes.devtools')
screen.getByText('DevToolsNode.int_input')
})
it('shows the extension filter only when more than one extension is present', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'only ext.', source: 's', extension: 'ext.alpha' })
const single = renderTab()
expect(
screen.queryByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
).toBeNull()
single.unmount()
store.report({
message: 'second ext.',
source: 's',
extension: 'ext.bravo'
})
renderTab()
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
})
it('filters the list to the selected extension', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
screen.getByText('from alpha')
screen.getByText('from bravo')
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.alpha' }))
screen.getByText('from alpha')
expect(screen.queryByText('from bravo')).toBeNull()
})
it('filters to warnings without an extension via the Unknown option', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'has ext',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({ message: 'no ext', source: 'litegraph' })
const { user } = renderTab()
screen.getByText('has ext')
screen.getByText('no ext')
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(
await screen.findByRole('option', {
name: 'deprecationWarnings.unknownExtension'
})
)
screen.getByText('no ext')
expect(screen.queryByText('has ext')).toBeNull()
})
it('clears the extension filter when warnings are cleared', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
expect(screen.queryByText('from alpha')).toBeNull()
await user.click(
screen.getByRole('button', { name: 'deprecationWarnings.clearAll' })
)
store.report({
message: 'fresh alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
await screen.findByText('fresh alpha')
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
})
it('drops a stale extension filter when its warnings are removed', async () => {
const store = useDeprecationWarningsStore()
store.report({
message: 'from alpha',
source: 'nodeDef',
extension: 'ext.alpha'
})
store.report({
message: 'from bravo',
source: 'nodeDef',
extension: 'ext.bravo'
})
const { user } = renderTab()
await user.click(
screen.getByRole('button', {
name: 'deprecationWarnings.filterByExtension'
})
)
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
expect(screen.queryByText('from alpha')).toBeNull()
for (const warning of store.warnings.filter(
(w) => w.extension === 'ext.bravo'
)) {
store.remove(warning.key)
}
await screen.findByText('from alpha')
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
})
it('renders a migration-guide link only when the warning has a docsUrl', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'no link here.' })
store.report({
message: 'has a guide.',
docsUrl: 'https://docs.example/guide'
})
renderTab()
const links = screen.getAllByRole('link', {
name: 'deprecationWarnings.learnMore'
})
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', 'https://docs.example/guide')
expect(links[0]).toHaveAttribute('target', '_blank')
expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer')
})
it('shows the per-warning count badge only when the warning has fired more than once', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'once' })
store.report({ message: 'twice' })
store.report({ message: 'twice' })
renderTab()
const badge = screen.getByTestId('deprecation-warning-badge')
expect(badge.textContent).toBe('2')
expect(badge.getAttribute('aria-label')).toBe(
'deprecationWarnings.occurrenceCount'
)
expect(screen.queryAllByTestId('deprecation-warning-badge')).toHaveLength(1)
})
it('marks all warnings as seen on mount', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.report({ message: 'two' })
expect(store.unseenCount).toBe(2)
renderTab()
expect(store.unseenCount).toBe(0)
})
it('marks warnings seen as they arrive while the panel is open', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'before mount' })
renderTab()
store.report({ message: 'arrived while open' })
await screen.findByText('arrived while open')
expect(store.unseenCount).toBe(0)
})
it('re-renders relative timestamps as time advances', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T12:00:00Z'))
const store = useDeprecationWarningsStore()
store.report({ message: 'aged-out' })
renderTab()
screen.getByText('g.relativeTime.now')
await vi.advanceTimersByTimeAsync(2 * 60_000)
screen.getByText('g.relativeTime.minutesAgo')
} finally {
vi.useRealTimers()
}
})
it('removes a single warning via its per-warning clear action', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'keep me' })
store.report({ message: 'remove me' })
renderTab()
const menus = screen.getAllByRole('button', {
name: 'deprecationWarnings.moreOptions'
})
// reka's Popover trigger doesn't toggle through userEvent's synthesized
// events under happy-dom, so drive the menu with native clicks.
// Warnings render newest-first, so the first menu targets 'remove me'.
menus[0].click()
;(await screen.findByText('deprecationWarnings.clear')).click()
await waitFor(() => {
expect(screen.queryByText('remove me')).toBeNull()
})
screen.getByText('keep me')
})
it('clear-all button empties the store and shows the empty state', async () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
const { user } = renderTab()
const clearAll = screen.getByRole('button', {
name: 'deprecationWarnings.clearAll'
})
await user.click(clearAll)
expect(store.warnings).toEqual([])
screen.getByText('deprecationWarnings.empty')
})
})

View File

@@ -0,0 +1,244 @@
<template>
<SidebarTabTemplate
data-testid="deprecation-warnings-sidebar"
:title="t('deprecationWarnings.title')"
>
<template #tool-buttons>
<Button
v-if="store.warnings.length > 0"
variant="muted-textonly"
size="md"
class="text-sm"
@click="clearWarnings"
>
<span>{{ t('deprecationWarnings.clearAll') }}</span>
</Button>
</template>
<template #body>
<NoResultsPlaceholder
v-if="store.warnings.length === 0"
icon="pi pi-check-circle"
:title="t('deprecationWarnings.emptyTitle')"
:message="t('deprecationWarnings.empty')"
/>
<template v-else>
<div
v-if="extensionOptions.length > 1"
class="flex justify-end px-3 pt-2"
>
<MultiSelect
v-model="selectedExtensions"
:options="extensionOptions"
:label="t('deprecationWarnings.filterByExtension')"
size="lg"
show-search-box
show-clear-button
class="w-fit"
/>
</div>
<NoResultsPlaceholder
v-if="filteredWarnings.length === 0"
icon="pi pi-filter-slash"
:title="t('deprecationWarnings.noMatchesTitle')"
:message="t('deprecationWarnings.noMatches')"
/>
<ul
v-else
class="mt-2 flex flex-col gap-2 px-3 pb-2"
data-testid="deprecation-warnings-list"
>
<li
v-for="warning in filteredWarnings"
:key="warning.key"
class="relative flex flex-col gap-2 rounded-md border border-interface-stroke p-4"
>
<div class="absolute top-2 right-2">
<Popover :show-arrow="false" :entries="warningMenuItems(warning)">
<template #button>
<Button
variant="muted-textonly"
size="md"
:aria-label="t('deprecationWarnings.moreOptions')"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</template>
</Popover>
</div>
<div class="flex items-start justify-between gap-2 pr-8">
<div
class="min-w-0 flex-1 text-sm wrap-break-word text-text-primary"
>
{{ warning.message }}
</div>
<div
class="flex shrink-0 items-center gap-2 text-sm text-text-secondary"
>
<span
:title="new Date(warning.lastSeenAt).toLocaleString(locale)"
>
{{
formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt))
}}
</span>
<Badge
v-if="warning.count > 1"
v-bind="occurrenceAttrs(warning.count)"
data-testid="deprecation-warning-badge"
severity="contrast"
variant="circle"
:label="formatBadgeCount(warning.count, 9)"
class="size-auto min-w-5 px-1.5 py-0.5 text-xs font-medium"
/>
</div>
</div>
<div
v-if="metaParts(warning).length"
class="flex flex-wrap items-center gap-1.5 font-mono text-xs wrap-break-word text-text-secondary"
>
<template v-for="(part, i) in metaParts(warning)" :key="i">
<span v-if="i > 0" aria-hidden="true" class="opacity-50"
>|</span
>
<span>{{ part }}</span>
</template>
</div>
<div
v-if="warning.suggestion || warning.docsUrl"
class="my-1 flex flex-col gap-2 rounded-md bg-node-component-surface p-3 text-sm"
>
<div v-if="warning.suggestion" class="wrap-break-word">
<span class="font-medium text-text-primary">{{
t('deprecationWarnings.suggestionLabel')
}}</span>
<span class="ml-1 text-text-secondary">{{
warning.suggestion
}}</span>
</div>
<Button
v-if="warning.docsUrl"
as="a"
variant="link"
size="sm"
:href="warning.docsUrl"
target="_blank"
rel="noopener noreferrer"
class="w-fit gap-2 px-0 text-sm underline"
>
<i class="icon-[lucide--book-open] size-4" />
<span>{{ t('deprecationWarnings.learnMore') }}</span>
</Button>
</div>
</li>
</ul>
</template>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Badge from '@/components/common/Badge.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import Popover from '@/components/ui/Popover.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import type { SelectOption } from '@/components/ui/select/types'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { formatBadgeCount } from '@/utils/numberUtil'
import { formatRelativeTime } from '@/utils/relativeTime'
const { t, locale } = useI18n()
const store = useDeprecationWarningsStore()
const UNKNOWN_EXTENSION = '__unknown__'
const selectedExtensions = ref<SelectOption[]>([])
const extensionOptions = computed<SelectOption[]>(() => {
const names = new Set<string>()
let hasUnknown = false
for (const warning of store.warnings) {
if (warning.extension) names.add(warning.extension)
else hasUnknown = true
}
const options = [...names].sort().map((name) => ({ name, value: name }))
if (hasUnknown) {
options.push({
name: t('deprecationWarnings.unknownExtension'),
value: UNKNOWN_EXTENSION
})
}
return options
})
// Prune selections whose option disappeared (e.g. its warnings were removed),
// otherwise a stale filter keeps hiding warnings once the control is hidden.
watch(extensionOptions, (options) => {
const valid = new Set(options.map((option) => option.value))
selectedExtensions.value = selectedExtensions.value.filter((option) =>
valid.has(option.value)
)
})
const filteredWarnings = computed(() => {
if (selectedExtensions.value.length === 0) return store.warnings
const selected = new Set(selectedExtensions.value.map((o) => o.value))
return store.warnings.filter((warning) =>
warning.extension != null
? selected.has(warning.extension)
: selected.has(UNKNOWN_EXTENSION)
)
})
const now = ref(Date.now())
useIntervalFn(() => {
now.value = Date.now()
}, 30_000)
function occurrenceAttrs(count: number) {
const text = t('deprecationWarnings.occurrenceCount', count)
return { 'aria-label': text, title: text }
}
function metaParts(warning: {
extension?: string
source?: string
detail?: string
}): string[] {
return [warning.extension, warning.source, warning.detail].filter(
(part): part is string => !!part
)
}
function clearWarnings() {
store.clear()
selectedExtensions.value = []
}
function warningMenuItems(warning: { key: string }): MenuItem[] {
return [
{
label: t('deprecationWarnings.clear'),
icon: 'icon-[lucide--trash-2] size-4',
command: () => store.remove(warning.key)
}
]
}
// watch, not watchEffect: markAllSeen mutates the watched source, and a
// watchEffect's recursion guard drops the subscription after a mount-time
// clear, leaving warnings that arrive while the panel is open unseen forever.
watch(
() => store.unseenCount,
(count) => {
if (count > 0) store.markAllSeen()
},
{ immediate: true }
)
</script>

View File

@@ -93,6 +93,30 @@ describe('MultiSelect', () => {
unmount()
})
it('closes when an outside pointerdown is stopped from propagating (e.g. canvas)', async () => {
const user = userEvent.setup()
// Mimics the litegraph canvas, which stops pointerdown from bubbling to the
// document — reka's bubble-phase outside-dismiss would never fire.
const canvasLike = document.body.appendChild(document.createElement('div'))
canvasLike.addEventListener('pointerdown', (e) => e.stopPropagation())
const { unmount } = renderInParent()
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'open')
canvasLike.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(trigger).toHaveAttribute('data-state', 'closed')
canvasLike.remove()
unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const user = userEvent.setup()

View File

@@ -47,6 +47,7 @@
<ComboboxPortal>
<ComboboxContent
ref="contentRef"
position="popper"
:side-offset="8"
align="start"
@@ -136,6 +137,7 @@
</template>
<script setup lang="ts">
import { useCurrentElement } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
@@ -166,6 +168,7 @@ import {
stopEscapeToDocument
} from '@/components/ui/select/select.variants'
import type { SelectOption } from '@/components/ui/select/types'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@comfyorg/tailwind-utils'
@@ -221,6 +224,19 @@ const { t } = useI18n()
const isOpen = ref(false)
const selectedCount = computed(() => selectedItems.value.length)
const rootEl = useCurrentElement<HTMLElement>()
const contentRef = ref<{ $el?: HTMLElement } | null>(null)
useDismissableOverlay({
isOpen,
getOverlayEl: () => contentRef.value?.$el ?? null,
getTriggerEl: () =>
rootEl.value instanceof HTMLElement ? rootEl.value : null,
onDismiss: () => {
isOpen.value = false
}
})
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)

View File

@@ -0,0 +1,58 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEPRECATION_WARNINGS_TAB_ID,
useDeprecationWarningsSidebarTab
} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
function iconBadgeOf(tab = useDeprecationWarningsSidebarTab()): string | null {
if (typeof tab.iconBadge !== 'function') {
throw new Error('expected iconBadge to be a function')
}
return tab.iconBadge()
}
describe('useDeprecationWarningsSidebarTab', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
// Drain warnings buffered during module-load imports.
useDeprecationWarningsStore().clear()
})
it('iconBadge returns null when there are no unseen warnings', () => {
expect(iconBadgeOf()).toBeNull()
})
it('iconBadge returns the unseen count when warnings are present', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.report({ message: 'two' })
expect(iconBadgeOf()).toBe('2')
})
it('iconBadge clamps the count at "9+"', () => {
const store = useDeprecationWarningsStore()
for (let i = 0; i < 12; i++) store.report({ message: `m${i}` })
expect(iconBadgeOf()).toBe('9+')
})
it('iconBadge returns null while the tab itself is active', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
useSidebarTabStore().activeSidebarTabId = DEPRECATION_WARNINGS_TAB_ID
expect(iconBadgeOf()).toBeNull()
})
it('iconBadge returns null after markAllSeen', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'one' })
store.markAllSeen()
expect(iconBadgeOf()).toBeNull()
})
})

View File

@@ -0,0 +1,30 @@
import { markRaw } from 'vue'
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { formatBadgeCount } from '@/utils/numberUtil'
export const DEPRECATION_WARNINGS_TAB_ID = 'deprecation-warnings'
export function useDeprecationWarningsSidebarTab(): SidebarTabExtension {
return {
id: DEPRECATION_WARNINGS_TAB_ID,
icon: 'icon-[lucide--triangle-alert]',
title: 'deprecationWarnings.title',
tooltip: 'deprecationWarnings.title',
label: 'deprecationWarnings.label',
component: markRaw(DeprecationWarningsTab),
type: 'vue',
placement: 'bottom',
iconBadge: () => {
const sidebarTabStore = useSidebarTabStore()
if (sidebarTabStore.activeSidebarTabId === DEPRECATION_WARNINGS_TAB_ID) {
return null
}
const count = useDeprecationWarningsStore().unseenCount
return count > 0 ? formatBadgeCount(count, 9) : null
}
}
}

View File

@@ -36,9 +36,9 @@ describe('Context Menu Extension Name in Warnings', () => {
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getCanvasMenuOptions')
expect(warningMessage).toContain('"MyCustomExtension"')
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
expect(warningMessage).toContain('detail: getCanvasMenuOptions')
expect(warningMessage).toContain('extension: MyCustomExtension')
vi.restoreAllMocks()
})
@@ -67,9 +67,9 @@ describe('Context Menu Extension Name in Warnings', () => {
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getNodeMenuOptions')
expect(warningMessage).toContain('"AnotherExtension"')
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
expect(warningMessage).toContain('detail: getNodeMenuOptions')
expect(warningMessage).toContain('extension: AnotherExtension')
vi.restoreAllMocks()
})

View File

@@ -1,11 +1,12 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app, ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { app, ComfyApp } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -150,11 +151,9 @@ app.registerExtension({
],
init() {
// Set up ComfyApp static methods for plugin compatibility (deprecated)
ComfyApp.open_maskeditor = openMaskEditorFromClipspace
console.warn(
'[MaskEditor] ComfyApp.open_maskeditor is deprecated. ' +
'Plugins should migrate to using the command system or direct node context menu integration.'
)
ComfyApp.open_maskeditor = function open_maskeditor() {
warnDeprecated('maskEditor.openMaskEditor')
openMaskEditorFromClipspace()
}
}
})

View File

@@ -0,0 +1,74 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { registerExtension } = vi.hoisted(() => ({
registerExtension: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: { registerExtension }
}))
import { convertToInput } from '@/extensions/core/widgetInputs'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
describe('widgetInputs deprecation', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
useDeprecationWarningsStore().clear()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
describe('convertToInput', () => {
it('returns the input slot whose widget name matches', () => {
const slot = { widget: { name: 'seed' } }
const node = createMockLGraphNode({
inputs: [slot, { widget: { name: 'other' } }]
})
expect(convertToInput(node, { name: 'seed' } as IBaseWidget)).toBe(slot)
})
it('reports a deprecation warning to the store', () => {
const node = createMockLGraphNode({ inputs: [] })
convertToInput(node, { name: 'x' } as IBaseWidget)
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
message: 'convertToInput is no longer necessary.',
source: 'widgetInputs'
})
})
})
describe('convertWidgetToInput prototype patch', () => {
it('reports a deprecation when the patched method is called', async () => {
const config = registerExtension.mock.calls.find(
([c]) => c?.name === 'Comfy.WidgetInputs'
)?.[0]
expect(config?.beforeRegisterNodeDef).toBeDefined()
class MockNode {}
await config.beforeRegisterNodeDef(MockNode, {})
const instance = new MockNode() as { convertWidgetToInput: () => boolean }
expect(instance.convertWidgetToInput()).toBe(false)
const store = useDeprecationWarningsStore()
expect(store.warnings[0]).toMatchObject({
message: 'convertWidgetToInput is no longer necessary.',
source: 'widgetInputs'
})
})
})
})

View File

@@ -13,6 +13,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -426,15 +427,12 @@ function getConfig(this: LGraphNode, widgetName: string) {
* @param node The node to convert the widget to an input slot for.
* @param widget The widget to convert to an input slot.
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
* @knipIgnoreUnusedButUsedByCustomNodes
*/
export function convertToInput(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
console.warn(
'Please remove call to convertToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
warnDeprecated('widgetInputs.convertToInput')
return node.inputs.find((slot) => slot.widget?.name === widget.name)
}
@@ -517,9 +515,7 @@ app.registerExtension({
) {
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
console.warn(
'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
)
warnDeprecated('widgetInputs.convertWidgetToInput')
return false
}

View File

@@ -13,6 +13,8 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { DEPRECATIONS } from '@/platform/dev/deprecations'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
@@ -552,13 +554,16 @@ describe('Subgraph Definition Garbage Collection', () => {
})
describe('beforeChange deprecated onBeforeChange shim', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
LiteGraph.onDeprecationWarning = []
LiteGraph.alwaysRepeatWarnings = true
setActivePinia(createTestingPinia({ stubActions: false }))
useDeprecationWarningsStore().clear()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = false
warnSpy.mockRestore()
})
it('still invokes a listener assigned to onBeforeChange', () => {
@@ -572,28 +577,25 @@ describe('beforeChange deprecated onBeforeChange shim', () => {
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
})
it('warns that onBeforeChange is deprecated when used', () => {
it('reports the onBeforeChange deprecation when used', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.onBeforeChange = vi.fn()
graph.beforeChange()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
undefined
expect(useDeprecationWarningsStore().warnings).toContainEqual(
expect.objectContaining({
message: DEPRECATIONS['litegraph.onBeforeChange'].message
})
)
})
it('does not warn when no listener is assigned', () => {
it('does not report when no listener is assigned', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.beforeChange()
expect(deprecationCallback).not.toHaveBeenCalled()
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
})
})

View File

@@ -58,7 +58,7 @@ import {
createBounds,
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1382,9 +1382,7 @@ export class LGraph
// used for undo, called before any change is made to the graph
beforeChange(info?: LGraphNode): void {
if (this.onBeforeChange) {
warnDeprecated(
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
)
warnDeprecated('litegraph.onBeforeChange')
this.onBeforeChange(this, info)
}
this.canvasAction((c) => c.onBeforeChange?.(this))

View File

@@ -8,6 +8,7 @@ import {
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
@@ -95,7 +96,6 @@ import type {
TWidgetValue
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -3521,9 +3521,7 @@ export class LGraphNode
* @deprecated Use {@link LGraphCanvas.pointer} instead.
*/
captureInput(v: boolean): void {
warnDeprecated(
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
)
warnDeprecated('litegraph.captureInput')
if (!this.graph || !this.graph.list_of_graphcanvas) return
const list = this.graph.list_of_graphcanvas

View File

@@ -28,8 +28,15 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { createUuidv4 } from '@/utils/uuid'
/** Never read; unfrozen so legacy `.push()` callers do not throw. */
const inertDeprecationListeners: ((
message: string,
source?: object
) => void)[] = []
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -273,18 +280,31 @@ export class LiteGraphGlobal {
context_menu_scaling = false
/**
* Debugging flag. Repeats deprecation warnings every time they are reported.
* May impact performance.
* @deprecated Removed; has no effect. Deprecation warnings are now surfaced in
* the Dev Mode "Deprecation Warnings" sidebar panel rather than dispatched to
* listeners. Retained so existing `.push()` callers do not crash; the array is
* never read.
*/
alwaysRepeatWarnings: boolean = false
get onDeprecationWarning(): ((message: string, source?: object) => void)[] {
warnDeprecated('litegraph.onDeprecationWarning')
return inertDeprecationListeners
}
/**
* Array of callbacks to execute when Litegraph first reports a deprecated API being used.
* @see alwaysRepeatWarnings By default, will not repeat identical messages.
*/
onDeprecationWarning: ((message: string, source?: object) => void)[] = [
console.warn
]
set onDeprecationWarning(
_value: ((message: string, source?: object) => void)[]
) {
warnDeprecated('litegraph.onDeprecationWarning')
}
/** @deprecated Removed; has no effect. */
get alwaysRepeatWarnings(): boolean {
warnDeprecated('litegraph.alwaysRepeatWarnings')
return false
}
set alwaysRepeatWarnings(_value: boolean) {
warnDeprecated('litegraph.alwaysRepeatWarnings')
}
/**
* @deprecated Removed; has no effect.

View File

@@ -140,7 +140,6 @@ LiteGraphGlobal {
"allow_multi_output_for_events": true,
"allow_scripts": false,
"alt_drag_do_clone_nodes": false,
"alwaysRepeatWarnings": false,
"alwaysSnapToGrid": undefined,
"auto_load_slot_types": false,
"canvasNavigationMode": "legacy",
@@ -167,9 +166,6 @@ LiteGraphGlobal {
"node_box_coloured_when_on": false,
"node_images_path": "",
"node_types_by_file_extension": {},
"onDeprecationWarning": [
[Function],
],
"overlapBounding": [Function],
"pointerevents_method": "pointer",
"proxy": null,

View File

@@ -64,12 +64,12 @@ describe('contextMenuCompat', () => {
// Should have logged a warning with extension name
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEPRECATED]'),
expect.stringContaining('[DEPRECATED'),
expect.any(String),
expect.any(String)
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Test Extension"'),
expect.stringContaining('extension: Test Extension'),
expect.any(String),
expect.any(String)
)

View File

@@ -1,5 +1,6 @@
import type { LGraphCanvas } from './LGraphCanvas'
import type { IContextMenuValue } from './interfaces'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
/**
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
@@ -86,13 +87,10 @@ class LegacyMenuCompat {
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\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'
)
warnDeprecated('litegraph.contextMenuMonkeyPatch', {
extension: this.currentExtension ?? undefined,
detail: methodName as string
})
}
currentImpl = newImpl
}

View File

@@ -1,5 +1,5 @@
import { clamp } from 'es-toolkit/compat'
import { describe, expect } from 'vitest'
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import {
LiteGraphGlobal,
@@ -27,6 +27,35 @@ describe('Litegraph module', () => {
expect(clamp(-1.124, 13, 24)).toStrictEqual(13)
expect(clamp(Infinity, 18, 29)).toStrictEqual(29)
})
describe('removed-API compatibility shims', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
test('onDeprecationWarning remains a usable no-op for old extensions', () => {
expect(() => LiteGraph.onDeprecationWarning.push(() => {})).not.toThrow()
expect(LiteGraph.onDeprecationWarning).toBe(
LiteGraph.onDeprecationWarning
)
expect(() => {
LiteGraph.onDeprecationWarning = [() => {}]
}).not.toThrow()
})
test('alwaysRepeatWarnings remains a usable no-op for old extensions', () => {
expect(() => {
LiteGraph.alwaysRepeatWarnings = true
}).not.toThrow()
expect(LiteGraph.alwaysRepeatWarnings).toBe(false)
})
})
})
describe('Import order dependency', () => {

View File

@@ -1,58 +0,0 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
/** Guard against unbound allocation. */
const UNIQUE_MESSAGE_LIMIT = 10_000
const sentWarnings: Set<string> = new Set()
/**
* Warns that a deprecated function has been used via the public
* {@link onDeprecationWarning} / {@link onEveryDeprecationWarning} callback arrays.
* @param message Plain-language detail about what has been deprecated. This **should not** include unique data; use {@link source}.
* @param source A reference object to include alongside the message, e.g. `this`.
*/
export function warnDeprecated(message: string, source?: object): void {
if (!LiteGraph.alwaysRepeatWarnings) {
// Do not repeat
if (sentWarnings.has(message)) return
// Hard limit of unique messages per session
if (sentWarnings.size > UNIQUE_MESSAGE_LIMIT) return
sentWarnings.add(message)
}
for (const callback of LiteGraph.onDeprecationWarning) {
callback(message, source)
}
}
/**
* Defines a deprecated property alias that proxies to a current property,
* logging a deprecation warning on first access.
*
* Warning is deduplicated by {@link warnDeprecated} (once per unique message per session).
*
* @param target The object to define the deprecated property on.
* @param deprecatedKey The old property name to deprecate.
* @param currentKey The new property name to proxy to.
* @param message The deprecation warning message.
*/
export function defineDeprecatedProperty<T>(
target: T,
deprecatedKey: string,
currentKey: keyof T & string,
message: string
): void {
Object.defineProperty(target, deprecatedKey, {
get() {
warnDeprecated(message)
return this[currentKey]
},
set(value: unknown) {
warnDeprecated(message)
this[currentKey] = value
},
configurable: true,
enumerable: false
})
}

View File

@@ -5,6 +5,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { ComboWidget } from '@/lib/litegraph/src/widgets/ComboWidget'
import { DEPRECATIONS } from '@/platform/dev/deprecations'
const { LGraphCanvas } = await vi.importActual<typeof LGraphCanvasModule>(
'@/lib/litegraph/src/LGraphCanvas'
@@ -557,9 +558,7 @@ describe('ComboWidget', () => {
})
it('should warn when using deprecated function values', () => {
const deprecationCallback = vi.fn()
const originalCallbacks = LiteGraph.onDeprecationWarning
LiteGraph.onDeprecationWarning = [deprecationCallback]
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
widget = new ComboWidget(
@@ -583,12 +582,15 @@ describe('ComboWidget', () => {
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(deprecationCallback).toHaveBeenCalledWith(
'Using a function for values is deprecated. Use an array of unique values instead.',
undefined
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
DEPRECATIONS['litegraph.comboValuesFunction'].message
),
expect.any(String),
expect.any(String)
)
LiteGraph.onDeprecationWarning = originalCallbacks
warnSpy.mockRestore()
})
})

View File

@@ -6,7 +6,7 @@ import type {
IComboWidget,
IStringComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { BaseSteppedWidget } from './BaseSteppedWidget'
import type { WidgetEventOptions } from './BaseWidget'
@@ -129,9 +129,7 @@ export class ComboWidget
// Deprecated functionality (warning as of v0.14.5)
if (typeof this.options.values === 'function') {
warnDeprecated(
'Using a function for values is deprecated. Use an array of unique values instead.'
)
warnDeprecated('litegraph.comboValuesFunction')
}
// Determine if clicked on left/right arrows

View File

@@ -1046,6 +1046,22 @@
}
}
},
"deprecationWarnings": {
"title": "Deprecation Warnings",
"label": "Warnings",
"emptyTitle": "All clear",
"empty": "No deprecation warnings reported in this session.",
"clear": "Clear",
"clearAll": "Clear all",
"moreOptions": "More options",
"suggestionLabel": "Fix:",
"learnMore": "Migration guide",
"filterByExtension": "Extension",
"unknownExtension": "Unknown",
"noMatchesTitle": "No matches",
"noMatches": "No warnings match the selected filters.",
"occurrenceCount": "Reported {count} time | Reported {count} times"
},
"helpCenter": {
"feedback": "Give Feedback",
"docs": "Docs",
@@ -1067,7 +1083,8 @@
"updateComfyUIStartedDetail": "ComfyUI update has been queued. Please wait...",
"updateComfyUISuccess": "Update Complete",
"updateComfyUISuccessDetail": "ComfyUI has been updated. Rebooting...",
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again."
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again.",
"missingDate": "Date unavailable"
},
"releaseToast": {
"newVersionAvailable": "New update is out!",

View File

@@ -63,7 +63,8 @@
"name": "Require confirmation when clearing workflow"
},
"Comfy_DevMode": {
"name": "Enable dev mode options (API save, etc.)"
"name": "Enable dev mode options (API save, etc.)",
"tooltip": "Enables the legacy \"Save (API Format)\" button, dev-only nodes in the node library, and a \"Deprecation Warnings\" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes."
},
"Comfy_DisableFloatRounding": {
"name": "Disable default float widget rounding.",

View File

@@ -0,0 +1,171 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type MockApi = { getRawLogs: ReturnType<typeof vi.fn> }
const mockState = vi.hoisted<{ api: MockApi }>(() => ({
api: { getRawLogs: undefined as never }
}))
vi.mock('@/scripts/api', () => ({
get api() {
return mockState.api
}
}))
function sample(path: string): string {
return `[DEPRECATION WARNING] Detected import of deprecated legacy API: ${path}. This is likely caused by a custom node extension using outdated APIs. Please update your extensions or contact the extension author for an updated version.`
}
describe('backfillServerDeprecations', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetModules()
mockState.api = { getRawLogs: vi.fn() }
})
it('seeds pre-load deprecation messages from the raw log buffer', async () => {
mockState.api.getRawLogs.mockResolvedValue({
entries: [
{ t: 't', m: 'unrelated startup message' },
{ t: 't', m: sample('/scripts/ui/legacy.js') }
]
})
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
await backfillServerDeprecations()
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0].message).toBe(
'Legacy API import: /scripts/ui/legacy.js'
)
})
it('extracts paths containing internal dots without truncation', async () => {
mockState.api.getRawLogs.mockResolvedValue({
entries: [{ t: 't', m: sample('/scripts/ui.foo.bar.js') }]
})
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
await backfillServerDeprecations()
expect(useDeprecationWarningsStore().warnings[0]?.message).toBe(
'Legacy API import: /scripts/ui.foo.bar.js'
)
})
it('falls back to the raw line (without the legacy-API suggestion) for entries that do not match the template', async () => {
mockState.api.getRawLogs.mockResolvedValue({
entries: [{ t: 't', m: '[DEPRECATION WARNING] something unexpected' }]
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
await backfillServerDeprecations()
warnSpy.mockRestore()
const [warning] = useDeprecationWarningsStore().warnings
expect(warning?.message).toBe('[DEPRECATION WARNING] something unexpected')
expect(warning?.suggestion).toBeUndefined()
})
it('ignores non-deprecation log entries', async () => {
mockState.api.getRawLogs.mockResolvedValue({
entries: [
{ t: 't', m: 'got prompt' },
{ t: 't', m: '[INFO] something else' }
]
})
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
await backfillServerDeprecations()
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
})
it('swallows getRawLogs failures gracefully and logs the error', async () => {
const failure = new Error('401 unauthenticated')
mockState.api.getRawLogs.mockRejectedValue(failure)
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await backfillServerDeprecations()
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
expect(errSpy).toHaveBeenCalledExactlyOnceWith(
'Failed to fetch initial server logs for deprecations:',
failure
)
errSpy.mockRestore()
})
it('only fetches once after a successful backfill', async () => {
const api = mockState.api
api.getRawLogs.mockResolvedValue({ entries: [] })
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
await backfillServerDeprecations()
await backfillServerDeprecations()
await backfillServerDeprecations()
expect(api.getRawLogs).toHaveBeenCalledTimes(1)
})
it('dedupes concurrent callers to a single fetch', async () => {
const api = mockState.api
api.getRawLogs.mockResolvedValue({ entries: [] })
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
await Promise.all([
backfillServerDeprecations(),
backfillServerDeprecations(),
backfillServerDeprecations()
])
expect(api.getRawLogs).toHaveBeenCalledTimes(1)
})
it('retries on a subsequent call after a failure', async () => {
const api = mockState.api
api.getRawLogs
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce({ entries: [] })
const { backfillServerDeprecations } =
await import('@/platform/dev/backfillServerDeprecations')
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await backfillServerDeprecations()
await backfillServerDeprecations()
errSpy.mockRestore()
expect(api.getRawLogs).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,54 @@
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { api } from '@/scripts/api'
const PREFIX = '[DEPRECATION WARNING]'
const LEGACY_API_PATH_REGEX = /:\s*(\S+?)\.\s+This is likely caused/
let backfilled = false
let backfillInProgress: Promise<void> | null = null
/**
* Seed the store from the backend's raw log buffer.
*/
export async function backfillServerDeprecations(): Promise<void> {
if (backfilled) return
if (backfillInProgress) return backfillInProgress
backfillInProgress = (async () => {
try {
const logs = await api.getRawLogs()
const store = useDeprecationWarningsStore()
for (const entry of logs.entries) {
if (!entry.m.includes(PREFIX)) continue
const path = LEGACY_API_PATH_REGEX.exec(entry.m)?.[1]
if (path) {
store.report({
message: `Legacy API import: ${path}`,
suggestion:
'Update the extension that imports this file, or ask its author to migrate.',
source: 'server'
})
} else {
console.warn(
'Server deprecation log did not match legacy-API template; falling back to raw line. Update LEGACY_API_PATH_REGEX:',
entry.m
)
store.report({
message: entry.m.trim(),
source: 'server'
})
}
}
backfilled = true
} catch (err) {
console.error(
'Failed to fetch initial server logs for deprecations:',
err
)
} finally {
backfillInProgress = null
}
})()
return backfillInProgress
}

View File

@@ -0,0 +1,121 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
describe('useDeprecationWarningsStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('appends a new warning when key is novel', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'thing is deprecated', source: 'frontend' })
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
message: 'thing is deprecated',
source: 'frontend',
count: 1
})
})
it('increments count and updates lastSeenAt on duplicate report', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'thing is deprecated', source: 'frontend' })
const initialLastSeenAt = store.warnings[0].lastSeenAt
vi.advanceTimersByTime(5_000)
store.report({ message: 'thing is deprecated', source: 'frontend' })
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0].count).toBe(2)
expect(store.warnings[0].lastSeenAt).toBe(initialLastSeenAt + 5_000)
})
it('treats different sources as distinct entries with the same message', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'foo', source: 'a' })
store.report({ message: 'foo', source: 'b' })
expect(store.warnings).toHaveLength(2)
})
it('orders warnings with the most recently seen first', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'first' })
store.report({ message: 'second' })
store.report({ message: 'third' })
vi.advanceTimersByTime(1_000)
store.report({ message: 'first' })
expect(store.warnings.map((w) => w.message)).toEqual([
'first',
'third',
'second'
])
})
it('stops tracking new keys once the cap is reached but keeps counting known ones', () => {
const store = useDeprecationWarningsStore()
for (let i = 0; i < 10_001; i++) {
store.report({ message: `dep-${i}` })
}
expect(store.warnings).toHaveLength(10_000)
const beforeRepeat = store.warnings.length
store.report({ message: 'dep-0' })
expect(store.warnings).toHaveLength(beforeRepeat)
expect(store.warnings.find((w) => w.message === 'dep-0')?.count).toBe(2)
})
it('exposes unseenCount that resets after markAllSeen', () => {
const store = useDeprecationWarningsStore()
expect(store.unseenCount).toBe(0)
store.report({ message: 'a' })
store.report({ message: 'b' })
expect(store.unseenCount).toBe(2)
store.markAllSeen()
expect(store.unseenCount).toBe(0)
store.report({ message: 'c' })
expect(store.unseenCount).toBe(1)
})
it('does not increase unseenCount when a duplicate is reported', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'a' })
store.markAllSeen()
store.report({ message: 'a' })
expect(store.unseenCount).toBe(0)
expect(store.warnings[0].count).toBe(2)
})
it('clear empties warnings and resets seen state', () => {
const store = useDeprecationWarningsStore()
store.report({ message: 'a' })
store.report({ message: 'b' })
store.markAllSeen()
store.clear()
expect(store.warnings).toEqual([])
expect(store.unseenCount).toBe(0)
store.report({ message: 'a' })
expect(store.warnings).toHaveLength(1)
expect(store.unseenCount).toBe(1)
})
})

View File

@@ -0,0 +1,132 @@
import { defineStore } from 'pinia'
import { computed, reactive } from 'vue'
interface DeprecationWarning {
key: string
message: string
suggestion?: string
/** Subsystem that reported the deprecation, e.g. 'nodeDef'. */
source?: string
/** Extension or node pack that triggered it, e.g. 'custom_nodes.devtools'. */
extension?: string
/** Specific locator within the source, e.g. an affected node/input or method. */
detail?: string
docsUrl?: string
count: number
lastSeenAt: number
}
export interface ReportDeprecationInput {
message: string
suggestion?: string
source?: string
extension?: string
detail?: string
docsUrl?: string
}
/** Guard against unbounded growth from high-cardinality reports. */
const MAX_TRACKED_DEPRECATIONS = 10_000
function deprecationKey(input: ReportDeprecationInput): string {
return JSON.stringify([
input.source ?? '',
input.message,
input.extension ?? '',
input.detail ?? ''
])
}
const pendingBuffer = new Map<
string,
{ input: ReportDeprecationInput; count: number }
>()
/**
* Buffers a deprecation reported before Pinia is active, deduped by key and
* capped. Returns true the first time a key is buffered, so callers log to
* the console once per unique deprecation.
*/
export function bufferDeprecation(input: ReportDeprecationInput): boolean {
const key = deprecationKey(input)
const buffered = pendingBuffer.get(key)
if (buffered) {
buffered.count += 1
return false
}
if (pendingBuffer.size >= MAX_TRACKED_DEPRECATIONS) return false
pendingBuffer.set(key, { input, count: 1 })
return true
}
export const useDeprecationWarningsStore = defineStore(
'deprecationWarnings',
() => {
const warningsByKey = reactive(new Map<string, DeprecationWarning>())
const unseenKeys = reactive(new Set<string>())
const warnings = computed<DeprecationWarning[]>(() =>
Array.from(warningsByKey.values()).reverse()
)
const unseenCount = computed(() => unseenKeys.size)
function report(
input: ReportDeprecationInput,
occurrences: number = 1
): boolean {
const key = deprecationKey(input)
const now = Date.now()
const existing = warningsByKey.get(key)
if (existing) {
existing.count += occurrences
existing.lastSeenAt = now
warningsByKey.delete(key)
warningsByKey.set(key, existing)
return false
}
if (warningsByKey.size >= MAX_TRACKED_DEPRECATIONS) return false
warningsByKey.set(key, {
key,
message: input.message,
suggestion: input.suggestion,
source: input.source,
extension: input.extension,
detail: input.detail,
docsUrl: input.docsUrl,
count: occurrences,
lastSeenAt: now
})
unseenKeys.add(key)
return true
}
function markAllSeen(): void {
unseenKeys.clear()
}
function clear(): void {
warningsByKey.clear()
unseenKeys.clear()
}
function remove(key: string): void {
warningsByKey.delete(key)
unseenKeys.delete(key)
}
for (const { input, count } of pendingBuffer.values()) report(input, count)
pendingBuffer.clear()
return {
warnings,
unseenCount,
report,
markAllSeen,
clear,
remove
}
}
)

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { formatDeprecationConsole } from '@/platform/dev/deprecations'
describe('formatDeprecationConsole', () => {
it('emits a bold, color-styled [DEPRECATED] tag', () => {
const [text, tagStyle, restStyle] = formatDeprecationConsole({
source: 'widget',
message: 'foo is deprecated.'
})
expect(text).toBe('%c[DEPRECATED:widget]%c foo is deprecated.')
expect(tagStyle).toBe('color: orange; font-weight: bold')
expect(restStyle).toBe('color: inherit')
})
it('omits the source from the tag when none is given', () => {
const [text] = formatDeprecationConsole({ message: 'foo is deprecated.' })
expect(text).toBe('%c[DEPRECATED]%c foo is deprecated.')
})
it('adds labelled lines for each present field in a uniform order', () => {
const [text] = formatDeprecationConsole({
source: 'litegraph',
message: 'foo is deprecated.',
extension: 'Comfy.Clipspace',
detail: 'getCanvasMenuOptions',
suggestion: 'Use bar instead.',
docsUrl: 'https://docs.example/guide'
})
expect(text).toBe(
[
'%c[DEPRECATED:litegraph]%c foo is deprecated.',
' extension: Comfy.Clipspace',
' detail: getCanvasMenuOptions',
' fix: Use bar instead.',
' docs: https://docs.example/guide'
].join('\n')
)
})
it('omits absent fields', () => {
const [text] = formatDeprecationConsole({
source: 'ChangeTracker',
message: 'checkState() is deprecated.',
suggestion: 'Use captureCanvasState().'
})
expect(text).toBe(
'%c[DEPRECATED:ChangeTracker]%c checkState() is deprecated.\n fix: Use captureCanvasState().'
)
})
})

View File

@@ -0,0 +1,143 @@
export interface DeprecationEntry {
/** Category/origin shown in the console tag and docs, e.g. 'widgetInputs'. */
source: string
/** Static description of what is deprecated. */
message: string
/** What to do instead. */
suggestion?: string
/** Version the deprecation was introduced, e.g. '1.40'. */
since?: string
/** Version the deprecated API is expected to be removed, e.g. '2.0'. */
removeBy?: string
/** Link to a migration guide or docs page for this deprecation. */
docsUrl?: string
}
/**
* Single source of truth for every known frontend deprecation, used both at
* runtime (call sites reference entries by id via `warnDeprecated`) and
* statically (the docs generator in `scripts/generate-deprecations-doc.ts`).
*/
export const DEPRECATIONS = {
'nodeDef.defaultInputRequired': {
source: 'nodeDef',
message: 'Use of defaultInput on a required input.',
suggestion:
'Drop the defaultInput option — required sockets are always present.'
},
'nodeDef.defaultInputOptional': {
source: 'nodeDef',
message: 'Use of defaultInput on an optional input.',
suggestion: 'Replace with forceInput.'
},
'litegraphService.setSizeForImage': {
source: 'litegraphService',
message: 'node.setSizeForImage is deprecated and has no effect.',
suggestion: 'Remove the call.'
},
'maskEditor.openMaskEditor': {
source: 'MaskEditor',
message: 'ComfyApp.open_maskeditor is deprecated.',
suggestion:
'Migrate to the command system or direct node context menu integration.'
},
'widgetInputs.convertToInput': {
source: 'widgetInputs',
message: 'convertToInput is no longer necessary.',
suggestion:
'Remove the call — widgets and sockets now co-exist on each input.'
},
'widgetInputs.convertWidgetToInput': {
source: 'widgetInputs',
message: 'convertWidgetToInput is no longer necessary.',
suggestion:
'Remove the call — widgets and sockets now co-exist on each input.'
},
'changeTracker.checkState': {
source: 'ChangeTracker',
message: 'ChangeTracker.checkState() is deprecated.',
suggestion: 'Call captureCanvasState() instead.'
},
'comfyUI.legacyQueueMenu': {
source: 'ComfyUI',
message: 'The legacy queue/history menu is deprecated and unsupported.',
suggestion:
'Switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".'
},
'comfySettings.getSettingValueDefault': {
source: 'ComfySettingsDialog',
message: 'getSettingValue() defaultValue parameter is deprecated.',
suggestion:
'Drop the argument — the default value in the setting definition will be used.'
},
'widget.inputEl': {
source: 'widget',
message: 'widget.inputEl is deprecated.',
suggestion: 'Use widget.element instead (renamed in PR #8594).'
},
'litegraph.comboValuesFunction': {
source: 'litegraph',
message: 'Using a function for combo values is deprecated.',
suggestion: 'Use an array of unique values instead.'
},
'litegraph.captureInput': {
source: 'litegraph',
message: 'captureInput will be removed in a future version.',
suggestion: 'Use LGraphCanvas.pointer (CanvasPointer) instead.'
},
'litegraph.contextMenuMonkeyPatch': {
source: 'litegraph',
message: 'Monkey-patching context menu methods is deprecated.',
suggestion: 'Use the new context menu API instead.',
docsUrl: 'https://docs.comfy.org/custom-nodes/js/context-menu-migration'
},
'litegraph.onDeprecationWarning': {
source: 'litegraph',
message:
'LiteGraph.onDeprecationWarning is no longer used and has no effect.',
suggestion:
'Remove the listener — there is no programmatic replacement; deprecation warnings now appear in the Dev Mode "Deprecation Warnings" sidebar panel.'
},
'litegraph.onBeforeChange': {
source: 'litegraph',
message:
'LGraph.onBeforeChange is deprecated and will be removed in a future version.',
suggestion: 'Assign a listener to LGraphCanvas.onBeforeChange instead.'
},
'litegraph.alwaysRepeatWarnings': {
source: 'litegraph',
message:
'LiteGraph.alwaysRepeatWarnings is no longer used and has no effect.',
suggestion:
'Remove the assignment — deprecation warnings are always deduplicated and tracked with occurrence counts in the Dev Mode "Deprecation Warnings" sidebar panel.'
}
} satisfies Record<string, DeprecationEntry>
export type DeprecationId = keyof typeof DEPRECATIONS
/**
* Builds the styled `console.warn` arguments shared by every deprecation
* warning: a bold `[DEPRECATED:<source>]` tag and message, followed by labelled
* lines for whichever fields are present, in a uniform order. Returns the args
* tuple to spread into `console.warn`.
*/
export function formatDeprecationConsole(input: {
source?: string
message: string
suggestion?: string
extension?: string
detail?: string
docsUrl?: string
}): [string, string, string] {
const tag = input.source ? `[DEPRECATED:${input.source}]` : '[DEPRECATED]'
const lines = [`%c${tag}%c ${input.message}`]
if (input.extension) lines.push(` extension: ${input.extension}`)
if (input.detail) lines.push(` detail: ${input.detail}`)
if (input.suggestion) lines.push(` fix: ${input.suggestion}`)
if (input.docsUrl) lines.push(` docs: ${input.docsUrl}`)
return [
lines.join('\n'),
'color: orange; font-weight: bold',
'color: inherit'
]
}

View File

@@ -0,0 +1,192 @@
import { createTestingPinia } from '@pinia/testing'
import { getActivePinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEPRECATIONS,
formatDeprecationConsole
} from '@/platform/dev/deprecations'
describe('warnDeprecated', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
setActivePinia(undefined)
vi.resetModules()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('formats the console message with the registry source tag, message and suggestion', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('widgetInputs.convertToInput')
const entry = DEPRECATIONS['widgetInputs.convertToInput']
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
...formatDeprecationConsole({
source: entry.source,
message: entry.message,
suggestion: entry.suggestion
})
)
})
it('forwards extension and detail as structured fields, not baked into the message', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('nodeDef.defaultInputRequired', {
extension: 'custom_nodes.devtools',
detail: 'NodeA.seed'
})
const entry = DEPRECATIONS['nodeDef.defaultInputRequired']
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
...formatDeprecationConsole({
source: entry.source,
message: entry.message,
suggestion: entry.suggestion,
extension: 'custom_nodes.devtools',
detail: 'NodeA.seed'
})
)
})
it('does not re-log to console on subsequent duplicate calls post-pinia', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('changeTracker.checkState')
warnDeprecated('changeTracker.checkState')
warnDeprecated('changeTracker.checkState')
expect(warnSpy).toHaveBeenCalledTimes(1)
})
it('keeps distinct detail as distinct entries so every affected target stays visible', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeB.seed' })
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(2)
expect(warnSpy).toHaveBeenCalledTimes(2)
const nodeA = store.warnings.find((w) => w.detail === 'NodeA.seed')
expect(nodeA?.count).toBe(2)
})
it('buffers warnings fired before pinia is active; drains them into the store on first access', async () => {
expect(getActivePinia()).toBeUndefined()
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('changeTracker.checkState')
warnDeprecated('maskEditor.openMaskEditor')
warnDeprecated('changeTracker.checkState')
// Console logs once per unique deprecation even before pinia is active —
// the repeated checkState warning must not log twice.
expect(warnSpy).toHaveBeenCalledTimes(2)
setActivePinia(createTestingPinia({ stubActions: false }))
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(2)
const checkState = store.warnings.find((w) =>
w.message.includes('checkState')
)
expect(checkState?.count).toBe(2)
})
it('caps the pre-pinia buffer so a hot loop cannot grow it unboundedly', async () => {
expect(getActivePinia()).toBeUndefined()
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
for (let i = 0; i < 10_001; i++) {
warnDeprecated('changeTracker.checkState', { detail: `call-${i}` })
}
setActivePinia(createTestingPinia({ stubActions: false }))
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
expect(useDeprecationWarningsStore().warnings).toHaveLength(10_000)
})
it('drains the pre-pinia buffer when the next warnDeprecated lands post-pinia', async () => {
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('changeTracker.checkState')
setActivePinia(createTestingPinia({ stubActions: false }))
warnDeprecated('maskEditor.openMaskEditor')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const store = useDeprecationWarningsStore()
expect(new Set(store.warnings.map((w) => w.source))).toEqual(
new Set([
DEPRECATIONS['maskEditor.openMaskEditor'].source,
DEPRECATIONS['changeTracker.checkState'].source
])
)
})
})
describe('defineDeprecatedProperty', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetModules()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('proxies reads to the current key and warns once with the registry entry', async () => {
const { defineDeprecatedProperty } =
await import('@/platform/dev/warnDeprecated')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const target: { element: string; inputEl?: string } = { element: 'hello' }
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
expect(target.inputEl).toBe('hello')
expect(target.inputEl).toBe('hello')
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject(DEPRECATIONS['widget.inputEl'])
})
it('proxies writes to the current key and warns', async () => {
const { defineDeprecatedProperty } =
await import('@/platform/dev/warnDeprecated')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const target: { element: string; inputEl?: string } = { element: 'a' }
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
target.inputEl = 'b'
expect(target.element).toBe('b')
expect(useDeprecationWarningsStore().warnings).toHaveLength(1)
})
})

View File

@@ -0,0 +1,79 @@
import { getActivePinia } from 'pinia'
import type {
DeprecationEntry,
DeprecationId
} from '@/platform/dev/deprecations'
import {
DEPRECATIONS,
formatDeprecationConsole
} from '@/platform/dev/deprecations'
import type { ReportDeprecationInput } from '@/platform/dev/deprecationWarningsStore'
import {
bufferDeprecation,
useDeprecationWarningsStore
} from '@/platform/dev/deprecationWarningsStore'
interface WarnDeprecatedOptions {
/** Extension or node pack that triggered the deprecation, surfaced as a tag. */
extension?: string
/** Specific locator within the source, e.g. an affected node/input or method. */
detail?: string
}
function resolveDeprecation(
id: DeprecationId,
options: WarnDeprecatedOptions
): ReportDeprecationInput {
const entry: DeprecationEntry = DEPRECATIONS[id]
return {
message: entry.message,
suggestion: entry.suggestion,
source: entry.source,
docsUrl: entry.docsUrl,
extension: options.extension,
detail: options.detail
}
}
export function warnDeprecated(
id: DeprecationId,
options: WarnDeprecatedOptions = {}
): void {
const input = resolveDeprecation(id, options)
if (!getActivePinia()) {
if (bufferDeprecation(input)) {
console.warn(...formatDeprecationConsole(input))
}
return
}
if (useDeprecationWarningsStore().report(input)) {
console.warn(...formatDeprecationConsole(input))
}
}
/**
* Defines a deprecated property alias that proxies to a current property,
* logging a deprecation warning via {@link warnDeprecated} on first access.
*/
export function defineDeprecatedProperty<T>(
target: T,
deprecatedKey: string,
currentKey: keyof T & string,
id: DeprecationId
): void {
Object.defineProperty(target, deprecatedKey, {
get() {
warnDeprecated(id)
return this[currentKey]
},
set(value: unknown) {
warnDeprecated(id)
this[currentKey] = value
},
configurable: true,
enumerable: false
})
}

View File

@@ -0,0 +1,30 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { DEPRECATIONS } from '@/platform/dev/deprecations'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
describe('Comfy.UseNewMenu setting', () => {
const setting = CORE_SETTINGS.find((s) => s.id === 'Comfy.UseNewMenu')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('reports the legacy-menu deprecation when set to Disabled', () => {
setting?.onChange?.('Disabled')
const warnings = useDeprecationWarningsStore().warnings
expect(warnings).toHaveLength(1)
expect(warnings[0]).toMatchObject(DEPRECATIONS['comfyUI.legacyQueueMenu'])
})
it('does not report a deprecation when the new menu is active', () => {
setting?.onChange?.('Top')
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
})
})

View File

@@ -3,6 +3,7 @@ import {
getDefaultLocale,
SUPPORTED_LOCALE_OPTIONS
} from '@/locales/localeConfig'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
@@ -571,6 +572,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.DevMode',
name: 'Enable dev mode options (API save, etc.)',
tooltip:
'Enables the legacy "Save (API Format)" button, dev-only nodes in the node library, and a "Deprecation Warnings" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes.',
type: 'boolean',
defaultValue: false,
onChange: (value) => {
@@ -612,6 +615,9 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'combo',
options: ['Disabled', 'Top'],
tooltip: 'Enable the redesigned top menu bar.',
onChange: (value) => {
if (value === 'Disabled') warnDeprecated('comfyUI.legacyQueueMenu')
},
migrateDeprecatedValue: (val: unknown) => {
const value = val as string
// Floating is now supported by dragging the docked actionbar off.

View File

@@ -1,6 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
import { defineDeprecatedProperty } from '@/platform/dev/warnDeprecated'
import {
bindMultilineTextareaWidget,
createMultilineInputElement
@@ -45,12 +45,7 @@ function addMultilineWidget(
widget.element = inputEl
/** @deprecated Use {@link widget.element} instead (renamed in PR #8594). */
defineDeprecatedProperty(
widget,
'inputEl',
'element',
'widget.inputEl is deprecated. Use widget.element instead.'
)
defineDeprecatedProperty(widget, 'inputEl', 'element', 'widget.inputEl')
widget.options.minNodeSize = [400, 200]
bindMultilineTextareaWidget(widget, inputEl)

View File

@@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat'
import { assert } from '@/base/assert'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -215,17 +216,10 @@ export class ChangeTracker {
/** @deprecated Use {@link captureCanvasState} instead. */
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
console.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
warnDeprecated('changeTracker.checkState')
this.captureCanvasState()
}
private static _checkStateWarned = false
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {

View File

@@ -245,13 +245,6 @@ class ComfyList {
this._reverse = reverse || false
this.element = $el('div.comfy-list') as HTMLDivElement
this.element.style.display = 'none'
console.warn(
'[ComfyUI] The legacy queue/history menu is deprecated. ' +
'Core functionality in this menu may break at any time. ' +
'Issues and feature requests related to the legacy menu will not be addressed. ' +
'To switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".'
)
}
get visible() {

View File

@@ -1,4 +1,5 @@
import { t } from '@/i18n'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -55,9 +56,7 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
defaultValue?: Settings[K]
): Settings[K] {
if (defaultValue !== undefined) {
console.warn(
`Parameter defaultValue is deprecated. The default value in settings definition will be used instead.`
)
warnDeprecated('comfySettings.getSettingValueDefault')
}
return useSettingStore().get(id)
}

View File

@@ -36,6 +36,7 @@ import type {
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -826,9 +827,7 @@ export const useLitegraphService = () => {
* @deprecated No longer needed as we use {@link useImagePreviewWidget}
*/
node.prototype.setSizeForImage = function (this: LGraphNode) {
console.warn(
'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.'
)
warnDeprecated('litegraphService.setSizeForImage')
}
node.prototype.onDrawBackground = function () {
updatePreviews(this)

View File

@@ -120,6 +120,32 @@ describe('commandStore', () => {
})
})
describe('unregisterCommand', () => {
it('removes a registered command', () => {
const store = useCommandStore()
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
expect(store.isRegistered('temp.cmd')).toBe(true)
store.unregisterCommand('temp.cmd')
expect(store.isRegistered('temp.cmd')).toBe(false)
expect(store.getCommand('temp.cmd')).toBeUndefined()
})
it('allows re-registering after unregister without a duplicate warning', () => {
const store = useCommandStore()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
store.unregisterCommand('temp.cmd')
store.registerCommand({ id: 'temp.cmd', function: vi.fn() })
expect(store.isRegistered('temp.cmd')).toBe(true)
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
})
describe('loadExtensionCommands', () => {
it('registers commands from an extension', () => {
const store = useCommandStore()

View File

@@ -88,6 +88,10 @@ export const useCommandStore = defineStore('command', () => {
}
}
const unregisterCommand = (id: string) => {
delete commandsById.value[id]
}
const getCommand = (command: string) => {
return commandsById.value[command]
}
@@ -139,6 +143,7 @@ export const useCommandStore = defineStore('command', () => {
getCommand,
registerCommand,
registerCommands,
unregisterCommand,
isRegistered,
loadExtensionCommands,
formatKeySequence

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -9,8 +9,9 @@ import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -420,3 +421,67 @@ describe('useNodeDefStore', () => {
})
})
})
describe('ComfyNodeDefImpl defaultInput deprecation', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
useDeprecationWarningsStore().clear()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('reports a deprecation when defaultInput is set on a required input', () => {
new ComfyNodeDefImpl({
name: 'N',
display_name: 'N',
category: 'c',
python_module: 'm',
description: '',
input: { required: { x: ['INT', { defaultInput: true }] } },
output: [],
output_is_list: [],
output_name: [],
output_node: false,
deprecated: false,
experimental: false
})
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
source: 'nodeDef',
extension: 'm',
detail: 'N.x'
})
})
it('reports a deprecation and migrates to forceInput on an optional input', () => {
const def = new ComfyNodeDefImpl({
name: 'N',
display_name: 'N',
category: 'c',
python_module: 'm',
description: '',
input: { optional: { y: ['INT', { defaultInput: true }] } },
output: [],
output_is_list: [],
output_name: [],
output_node: false,
deprecated: false,
experimental: false
})
const store = useDeprecationWarningsStore()
expect(store.warnings[0]).toMatchObject({
source: 'nodeDef',
extension: 'm',
detail: 'N.y'
})
expect(def.inputs.y).toMatchObject({ forceInput: true })
})
})

View File

@@ -9,6 +9,7 @@ import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConc
import { resolveInputType } from '@/core/graph/widgets/dynamicTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
import type {
ComfyNodeDef as ComfyNodeDefV2,
@@ -113,9 +114,10 @@ export class ComfyNodeDefImpl
for (const [name, spec] of Object.entries(def.input.required ?? {})) {
const inputOptions = spec[1]
if (inputOptions && inputOptions.defaultInput) {
console.warn(
`Use of defaultInput on required input ${nodeDef.python_module}:${nodeDef.name}:${name} is deprecated. Please drop the defaultInput option.`
)
warnDeprecated('nodeDef.defaultInputRequired', {
extension: nodeDef.python_module,
detail: `${nodeDef.name}.${name}`
})
}
}
// For optional inputs, defaultInput is used to distinguish the null state.
@@ -124,9 +126,10 @@ export class ComfyNodeDefImpl
for (const [name, spec] of Object.entries(def.input.optional ?? {})) {
const inputOptions = spec[1]
if (inputOptions && inputOptions.defaultInput) {
console.warn(
`Use of defaultInput on optional input ${nodeDef.python_module}:${nodeDef.name}:${name} is deprecated. Please use forceInput instead.`
)
warnDeprecated('nodeDef.defaultInputOptional', {
extension: nodeDef.python_module,
detail: `${nodeDef.name}.${name}`
})
inputOptions.forceInput = true
}
}

View File

@@ -5,12 +5,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn()
}))
const {
mockGetSetting,
mockRegisterCommand,
mockRegisterCommands,
mockUnregisterCommand
} = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn(),
mockUnregisterCommand: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
@@ -21,6 +26,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand,
unregisterCommand: mockUnregisterCommand,
commands: []
})
}))
@@ -54,6 +60,16 @@ vi.mock('@/composables/sidebarTabs/useJobHistorySidebarTab', () => ({
})
}))
vi.mock('@/composables/sidebarTabs/useDeprecationWarningsSidebarTab', () => ({
DEPRECATION_WARNINGS_TAB_ID: 'deprecation-warnings',
useDeprecationWarningsSidebarTab: () => ({
id: 'deprecation-warnings',
title: 'deprecation-warnings',
type: 'vue',
component: {}
})
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library',
@@ -96,9 +112,7 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
describe('useSidebarTabStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockGetSetting.mockReset()
mockRegisterCommand.mockClear()
mockRegisterCommands.mockClear()
vi.clearAllMocks()
})
it('registers the job history tab when QPO V2 is enabled', () => {
@@ -160,4 +174,82 @@ describe('useSidebarTabStore', () => {
])
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
})
it('registers the deprecation warnings tab when DevMode is enabled at init', () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.DevMode' ? true : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs.map((tab) => tab.id)).toContain(
'deprecation-warnings'
)
})
it('does not register the deprecation warnings tab when DevMode is disabled', () => {
mockGetSetting.mockImplementation(() => false)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain(
'deprecation-warnings'
)
})
it('registers and unregisters the deprecation warnings tab when DevMode is toggled', async () => {
const devMode = ref(false)
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.DevMode' ? devMode.value : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain(
'deprecation-warnings'
)
devMode.value = true
await nextTick()
expect(store.sidebarTabs.map((tab) => tab.id)).toContain(
'deprecation-warnings'
)
devMode.value = false
await nextTick()
expect(store.sidebarTabs.map((tab) => tab.id)).not.toContain(
'deprecation-warnings'
)
})
it('unregisters the toggle command when DevMode flips off so re-enabling does not warn', async () => {
const devMode = ref(false)
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.DevMode' ? devMode.value : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
devMode.value = true
await nextTick()
devMode.value = false
await nextTick()
devMode.value = true
await nextTick()
expect(store.sidebarTabs.map((tab) => tab.id)).toContain(
'deprecation-warnings'
)
expect(mockUnregisterCommand).toHaveBeenCalledWith(
'Workspace.ToggleSidebarTab.deprecation-warnings'
)
const deprecationRegistrations = mockRegisterCommand.mock.calls.filter(
([cmd]) => cmd.id === 'Workspace.ToggleSidebarTab.deprecation-warnings'
)
expect(deprecationRegistrations).toHaveLength(2)
})
})

View File

@@ -2,6 +2,10 @@ import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
import {
DEPRECATION_WARNINGS_TAB_ID,
useDeprecationWarningsSidebarTab
} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab'
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
@@ -53,7 +57,8 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
'model-library': 'sideToolbar.modelLibrary',
workflows: 'sideToolbar.workflows',
assets: 'sideToolbar.assets',
'job-history': 'queue.jobHistory'
'job-history': 'queue.jobHistory',
[DEPRECATION_WARNINGS_TAB_ID]: 'deprecationWarnings.title'
}
const key = menubarLabelKeys[tab.id]
@@ -106,6 +111,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
if (activeSidebarTabId.value === id) {
activeSidebarTabId.value = null
}
useCommandStore().unregisterCommand(`Workspace.ToggleSidebarTab.${id}`)
}
}
@@ -114,6 +120,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
*/
const registerCoreSidebarTabs = () => {
const settingStore = useSettingStore()
const jobHistoryTabId = 'job-history'
const syncJobHistoryTab = (enabled: boolean) => {
const hasJobHistoryTab = sidebarTabs.value.some(
@@ -125,13 +132,28 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
unregisterSidebarTab(jobHistoryTabId)
}
}
syncJobHistoryTab(settingStore.get('Comfy.Queue.QPOV2'))
watch(
() => settingStore.get('Comfy.Queue.QPOV2'),
(enabled) => syncJobHistoryTab(enabled)
)
const syncDeprecationWarningsTab = (enabled: boolean) => {
const has = sidebarTabs.value.some(
(tab) => tab.id === DEPRECATION_WARNINGS_TAB_ID
)
if (enabled && !has) {
registerSidebarTab(useDeprecationWarningsSidebarTab())
} else if (!enabled && has) {
unregisterSidebarTab(DEPRECATION_WARNINGS_TAB_ID)
}
}
syncDeprecationWarningsTab(settingStore.get('Comfy.DevMode'))
watch(
() => settingStore.get('Comfy.DevMode'),
(enabled) => syncDeprecationWarningsTab(enabled)
)
registerSidebarTab(useAssetsSidebarTab())
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())

View File

@@ -12,6 +12,8 @@ interface BaseSidebarTabExtension {
iconBadge?: string | (() => string | null)
tooltip?: string
label?: string
/** Render in the top or bottom-anchored group. Defaults to `'top'`. */
placement?: 'top' | 'bottom'
}
interface BaseBottomPanelExtension {

View File

@@ -100,5 +100,8 @@ declare global {
__appReadiness?: AppReadiness
__comfyDesktop2?: ComfyDesktop2Bridge
/** Injects a synthetic deprecation warning; only installed in dev mode. */
__reportDeprecation?: (message: string) => void
}
}

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { clampPercentInt, formatPercent0, formatUsdCents } from './numberUtil'
import {
clampPercentInt,
formatBadgeCount,
formatPercent0,
formatUsdCents
} from './numberUtil'
describe('clampPercentInt', () => {
it('clamps undefined to 0', () => {
@@ -69,3 +74,22 @@ describe('formatUsdCents', () => {
expect(formatUsdCents('en-US', 200000)).toBe('$2,000')
})
})
describe('formatBadgeCount', () => {
it('returns the count as a string when at or below the default cap', () => {
expect(formatBadgeCount(0)).toBe('0')
expect(formatBadgeCount(1)).toBe('1')
expect(formatBadgeCount(99)).toBe('99')
})
it('returns "99+" when the count exceeds the default cap', () => {
expect(formatBadgeCount(100)).toBe('99+')
expect(formatBadgeCount(1000)).toBe('99+')
})
it('respects a custom max', () => {
expect(formatBadgeCount(9, 9)).toBe('9')
expect(formatBadgeCount(10, 9)).toBe('9+')
expect(formatBadgeCount(42, 9)).toBe('9+')
})
})

View File

@@ -44,3 +44,7 @@ export const formatUsdCents = (locale: string, cents: number): string => {
maximumFractionDigits: hasFractionalCents ? 2 : 0
}).format(cents / 100)
}
export function formatBadgeCount(count: number, max = 99): string {
return count > max ? `${max}+` : count.toString()
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const MINUTE_MS = 60_000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
const WEEK_MS = 7 * DAY_MS
const MONTH_MS = 30 * DAY_MS
const YEAR_MS = 365 * DAY_MS
function t(key: string, named?: { count: number }): string {
return named ? `${key}:${named.count}` : key
}
describe('formatRelativeTime', () => {
it('returns "now" when below one minute', () => {
expect(formatRelativeTime(t, 0)).toBe('g.relativeTime.now')
expect(formatRelativeTime(t, MINUTE_MS - 1)).toBe('g.relativeTime.now')
})
it('picks the largest unit that fits', () => {
expect(formatRelativeTime(t, MINUTE_MS)).toBe('g.relativeTime.minutesAgo:1')
expect(formatRelativeTime(t, 59 * MINUTE_MS)).toBe(
'g.relativeTime.minutesAgo:59'
)
expect(formatRelativeTime(t, HOUR_MS)).toBe('g.relativeTime.hoursAgo:1')
expect(formatRelativeTime(t, 23 * HOUR_MS)).toBe(
'g.relativeTime.hoursAgo:23'
)
expect(formatRelativeTime(t, DAY_MS)).toBe('g.relativeTime.daysAgo:1')
expect(formatRelativeTime(t, WEEK_MS)).toBe('g.relativeTime.weeksAgo:1')
expect(formatRelativeTime(t, MONTH_MS)).toBe('g.relativeTime.monthsAgo:1')
expect(formatRelativeTime(t, YEAR_MS)).toBe('g.relativeTime.yearsAgo:1')
})
it('passes the integer count to the translator', () => {
const spy = vi.fn(t)
formatRelativeTime(spy, 5 * MINUTE_MS + 30 * 1000)
expect(spy).toHaveBeenCalledExactlyOnceWith('g.relativeTime.minutesAgo', {
count: 5
})
})
})

25
src/utils/relativeTime.ts Normal file
View File

@@ -0,0 +1,25 @@
const MINUTE_MS = 60_000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
const WEEK_MS = 7 * DAY_MS
const MONTH_MS = 30 * DAY_MS
const YEAR_MS = 365 * DAY_MS
const UNITS: readonly [number, string][] = [
[YEAR_MS, 'yearsAgo'],
[MONTH_MS, 'monthsAgo'],
[WEEK_MS, 'weeksAgo'],
[DAY_MS, 'daysAgo'],
[HOUR_MS, 'hoursAgo'],
[MINUTE_MS, 'minutesAgo']
]
type Translator = (key: string, named?: { count: number }) => string
export function formatRelativeTime(t: Translator, diffMs: number): string {
for (const [ms, suffix] of UNITS) {
const value = Math.floor(diffMs / ms)
if (value > 0) return t(`g.relativeTime.${suffix}`, { count: value })
}
return t('g.relativeTime.now')
}

View File

@@ -65,6 +65,8 @@ import { setActiveLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import DesktopCloudNotificationController from '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue'
import { backfillServerDeprecations } from '@/platform/dev/backfillServerDeprecations'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -229,6 +231,19 @@ useSidebarTabStore().registerCoreSidebarTabs()
void useBottomPanelStore().registerCoreBottomPanelTabs()
useQueuePolling()
// The server retains its log buffer, so enabling DevMode later still backfills.
watchEffect(() => {
if (settingStore.get('Comfy.DevMode')) {
void backfillServerDeprecations()
window.__reportDeprecation = (message) => {
useDeprecationWarningsStore().report({ message })
}
} else {
delete window.__reportDeprecation
}
})
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const sidebarTabStore = useSidebarTabStore()