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
This commit is contained in:
pythongosssss
2026-05-26 08:40:46 -07:00
parent ceb5ae1eba
commit 315fd0bc39
40 changed files with 1624 additions and 132 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,30 @@ 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.evaluate((m: string) => {
window.LiteGraph!.onDeprecationWarning.forEach((cb) => cb(m))
}, message)
}
}

View File

@@ -0,0 +1,96 @@
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)
})
})
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)
})
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

@@ -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,
@@ -492,28 +483,8 @@ 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')
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,116 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } 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('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('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('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,99 @@
<template>
<SidebarTabTemplate
data-testid="deprecation-warnings-sidebar"
:title="t('deprecationWarnings.title')"
>
<template #tool-buttons>
<Button
v-if="store.warnings.length > 0"
variant="textonly"
size="sm"
@click="store.clear()"
>
<i class="icon-[lucide--trash-2] size-4" />
<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')"
/>
<ul
v-else
class="flex flex-col gap-2 px-3 py-2"
data-testid="deprecation-warnings-list"
>
<li
v-for="warning in store.warnings"
:key="warning.key"
class="flex flex-col gap-0.5 rounded-sm border border-interface-stroke p-4"
>
<div class="flex items-start justify-between gap-2">
<div
class="min-w-0 flex-1 text-base wrap-break-word text-text-primary"
>
{{ warning.message }}
</div>
<span
v-if="warning.count > 1"
v-bind="occurrenceAttrs(warning.count)"
data-testid="deprecation-warning-badge"
class="inline-flex min-w-5 shrink-0 items-center justify-center rounded-full bg-primary-background px-1.5 py-0.5 text-xs font-medium text-base-foreground"
>
{{ formatBadgeCount(warning.count, 9) }}
</span>
</div>
<div
v-if="warning.suggestion"
class="my-1 flex flex-wrap gap-x-1 rounded-sm bg-node-component-surface px-2 py-1.5 text-sm wrap-break-word"
>
<span class="font-medium text-text-primary">
{{ t('deprecationWarnings.suggestionLabel') }}
</span>
<span class="text-text-secondary">{{ warning.suggestion }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span v-if="warning.source">{{ warning.source }}</span>
<span v-if="warning.source" aria-hidden="true">·</span>
<span :title="new Date(warning.lastSeenAt).toLocaleString(locale)">
{{ formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt)) }}
</span>
</div>
</li>
</ul>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
import { formatBadgeCount } from '@/utils/numberUtil'
import { formatRelativeTime } from '@/utils/relativeTime'
const { t, locale } = useI18n()
const store = useDeprecationWarningsStore()
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 }
}
onMounted(() => {
store.markAllSeen()
})
</script>

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

@@ -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,13 @@ 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('ComfyApp.open_maskeditor is deprecated.', {
suggestion:
'Migrate to the command system or direct node context menu integration.',
source: 'MaskEditor'
})
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,16 @@ 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('convertToInput is no longer necessary.', {
suggestion:
'Remove the call — widgets and sockets now co-exist on each input.',
source: 'widgetInputs'
})
return node.inputs.find((slot) => slot.widget?.name === widget.name)
}
@@ -517,9 +519,11 @@ 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('convertWidgetToInput is no longer necessary.', {
suggestion:
'Remove the call — widgets and sockets now co-exist on each input.',
source: 'widgetInputs'
})
return false
}

View File

@@ -95,7 +95,7 @@ import type {
TWidgetValue
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { dispatchLitegraphDeprecation } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -3521,7 +3521,7 @@ export class LGraphNode
* @deprecated Use {@link LGraphCanvas.pointer} instead.
*/
captureInput(v: boolean): void {
warnDeprecated(
dispatchLitegraphDeprecation(
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
)
if (!this.graph || !this.graph.list_of_graphcanvas) return

View File

@@ -5,19 +5,23 @@ 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.
* Litegraph-internal deprecation dispatcher. Fires
* {@link LiteGraph.onDeprecationWarning} callbacks once per unique message
* (unless {@link LiteGraph.alwaysRepeatWarnings}).
*
* Frontend code outside the litegraph package should use
* `warnDeprecated` from `@/platform/dev/warnDeprecated` instead.
*
* @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 {
export function dispatchLitegraphDeprecation(
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)
}
@@ -25,34 +29,3 @@ export function warnDeprecated(message: string, source?: object): void {
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

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

View File

@@ -1046,6 +1046,15 @@
}
}
},
"deprecationWarnings": {
"title": "Deprecation Warnings",
"label": "Warnings",
"emptyTitle": "All clear",
"empty": "No deprecation warnings reported in this session.",
"clearAll": "Clear all",
"suggestionLabel": "Fix:",
"occurrenceCount": "Reported {count} time | Reported {count} times"
},
"helpCenter": {
"feedback": "Give Feedback",
"docs": "Docs",

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,155 @@
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('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,42 @@
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
/**
* Seed the store from the backend's raw log buffer.
*/
export async function backfillServerDeprecations(): Promise<void> {
if (backfilled) return
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)
}
}

View File

@@ -0,0 +1,91 @@
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('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,84 @@
import { defineStore } from 'pinia'
import { computed, reactive } from 'vue'
export interface DeprecationWarning {
key: string
message: string
suggestion?: string
source?: string
count: number
lastSeenAt: number
}
export interface ReportDeprecationInput {
message: string
suggestion?: string
source?: string
}
export function deprecationKey(input: ReportDeprecationInput): string {
return `${input.source ?? ''}::${input.message}`
}
const pendingBuffer: ReportDeprecationInput[] = []
export function bufferDeprecation(input: ReportDeprecationInput): void {
pendingBuffer.push(input)
}
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()).sort(
(a, b) => b.lastSeenAt - a.lastSeenAt
)
)
const unseenCount = computed(() => unseenKeys.size)
function report(input: ReportDeprecationInput): boolean {
const key = deprecationKey(input)
const now = Date.now()
const existing = warningsByKey.get(key)
if (existing) {
existing.count += 1
existing.lastSeenAt = now
return false
}
warningsByKey.set(key, {
key,
message: input.message,
suggestion: input.suggestion,
source: input.source,
count: 1,
lastSeenAt: now
})
unseenKeys.add(key)
return true
}
function markAllSeen(): void {
unseenKeys.clear()
}
function clear(): void {
warningsByKey.clear()
unseenKeys.clear()
}
for (const input of pendingBuffer.splice(0)) report(input)
return {
warnings,
unseenCount,
report,
markAllSeen,
clear
}
}
)

View File

@@ -0,0 +1,70 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('installLiteGraphDeprecationBridge', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetModules()
})
it('forwards LiteGraph deprecation callbacks into the store', async () => {
const { LiteGraph } = await import('@/lib/litegraph/src/litegraph')
const { installLiteGraphDeprecationBridge } =
await import('@/platform/dev/installLiteGraphDeprecationBridge')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const baseline = LiteGraph.onDeprecationWarning.length
installLiteGraphDeprecationBridge()
expect(LiteGraph.onDeprecationWarning).toHaveLength(baseline + 1)
const bridgeCallback = LiteGraph.onDeprecationWarning.at(-1)!
bridgeCallback('bridge-test-message')
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
message: 'bridge-test-message',
source: 'litegraph'
})
})
it('only registers one forwarding callback no matter how many times install is called', async () => {
const { LiteGraph } = await import('@/lib/litegraph/src/litegraph')
const { installLiteGraphDeprecationBridge } =
await import('@/platform/dev/installLiteGraphDeprecationBridge')
const baseline = LiteGraph.onDeprecationWarning.length
installLiteGraphDeprecationBridge()
installLiteGraphDeprecationBridge()
installLiteGraphDeprecationBridge()
expect(LiteGraph.onDeprecationWarning).toHaveLength(baseline + 1)
})
it('drains warnings buffered before pinia was active when the first LiteGraph callback fires', async () => {
setActivePinia(undefined)
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('early-frontend', { source: 'frontend' })
setActivePinia(createTestingPinia({ stubActions: false }))
const { LiteGraph } = await import('@/lib/litegraph/src/litegraph')
const { installLiteGraphDeprecationBridge } =
await import('@/platform/dev/installLiteGraphDeprecationBridge')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
installLiteGraphDeprecationBridge()
LiteGraph.onDeprecationWarning.at(-1)!('lg-message')
const store = useDeprecationWarningsStore()
expect(store.warnings.map((w) => w.message).sort()).toEqual([
'early-frontend',
'lg-message'
])
})
})

View File

@@ -0,0 +1,16 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
let installed = false
export function installLiteGraphDeprecationBridge(): void {
if (installed) return
installed = true
LiteGraph.onDeprecationWarning.push((message: string) => {
useDeprecationWarningsStore().report({
message,
source: 'litegraph'
})
})
}

View File

@@ -0,0 +1,154 @@
import { createTestingPinia } from '@pinia/testing'
import { getActivePinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 source tag and suggestion suffix', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('use bar() instead of foo()', {
source: 'frontend',
suggestion: 'Migrate by 2.0.'
})
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
'[DEPRECATED:frontend] use bar() instead of foo() Migrate by 2.0.'
)
})
it('omits the source tag when no source is provided', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('something is deprecated')
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
'[DEPRECATED] something is deprecated'
)
})
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('same', { source: 'a' })
warnDeprecated('same', { source: 'a' })
warnDeprecated('same', { source: 'a' })
expect(warnSpy).toHaveBeenCalledTimes(1)
})
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('early one', { source: 'boot' })
warnDeprecated('early two', { source: 'boot' })
warnDeprecated('early one', { source: 'boot' })
expect(warnSpy).toHaveBeenCalledTimes(3)
expect(warnSpy).toHaveBeenNthCalledWith(1, '[DEPRECATED:boot] early one')
expect(warnSpy).toHaveBeenNthCalledWith(2, '[DEPRECATED:boot] early two')
expect(warnSpy).toHaveBeenNthCalledWith(3, '[DEPRECATED:boot] early one')
setActivePinia(createTestingPinia({ stubActions: false }))
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(2)
const earlyOne = store.warnings.find((w) => w.message === 'early one')
expect(earlyOne?.count).toBe(2)
})
it('drains the pre-pinia buffer when the next warnDeprecated lands post-pinia', async () => {
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
warnDeprecated('buffered', { source: 'boot' })
setActivePinia(createTestingPinia({ stubActions: false }))
warnDeprecated('post-pinia', { source: 'late' })
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const store = useDeprecationWarningsStore()
expect(store.warnings.map((w) => w.message).sort()).toEqual([
'buffered',
'post-pinia'
])
})
})
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', async () => {
const { defineDeprecatedProperty } =
await import('@/platform/dev/warnDeprecated')
const { useDeprecationWarningsStore } =
await import('@/platform/dev/deprecationWarningsStore')
const target: { newName: string; oldName?: string } = { newName: 'hello' }
defineDeprecatedProperty(
target,
'oldName',
'newName',
'target.oldName is deprecated.',
{ source: 'unit-test', suggestion: 'Use target.newName.' }
)
expect(target.oldName).toBe('hello')
expect(target.oldName).toBe('hello')
const store = useDeprecationWarningsStore()
expect(store.warnings).toHaveLength(1)
expect(store.warnings[0]).toMatchObject({
message: 'target.oldName is deprecated.',
suggestion: 'Use target.newName.',
source: 'unit-test'
})
})
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: { newName: string; oldName?: string } = { newName: 'a' }
defineDeprecatedProperty(
target,
'oldName',
'newName',
'target.oldName is deprecated.'
)
target.oldName = 'b'
expect(target.newName).toBe('b')
expect(useDeprecationWarningsStore().warnings).toHaveLength(1)
})
})

View File

@@ -0,0 +1,57 @@
import { getActivePinia } from 'pinia'
import type { ReportDeprecationInput } from '@/platform/dev/deprecationWarningsStore'
import {
bufferDeprecation,
useDeprecationWarningsStore
} from '@/platform/dev/deprecationWarningsStore'
export type WarnDeprecatedOptions = Omit<ReportDeprecationInput, 'message'>
function formatConsoleMessage(input: ReportDeprecationInput): string {
const tag = input.source ? `[DEPRECATED:${input.source}]` : '[DEPRECATED]'
const suggestionSuffix = input.suggestion ? ` ${input.suggestion}` : ''
return `${tag} ${input.message}${suggestionSuffix}`
}
export function warnDeprecated(
message: string,
options: WarnDeprecatedOptions = {}
): void {
const input: ReportDeprecationInput = { message, ...options }
if (!getActivePinia()) {
bufferDeprecation(input)
console.warn(formatConsoleMessage(input))
return
}
if (useDeprecationWarningsStore().report(input)) {
console.warn(formatConsoleMessage(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,
message: string,
options: WarnDeprecatedOptions = {}
): void {
Object.defineProperty(target, deprecatedKey, {
get() {
warnDeprecated(message, options)
return this[currentKey]
},
set(value: unknown) {
warnDeprecated(message, options)
this[currentKey] = value
},
configurable: true,
enumerable: false
})
}

View File

@@ -571,6 +571,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) => {

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

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,13 @@ 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() is deprecated.', {
suggestion: 'Call captureCanvasState() instead.',
source: 'ChangeTracker'
})
this.captureCanvasState()
}
private static _checkStateWarned = false
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {

View File

@@ -1,4 +1,5 @@
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
@@ -246,11 +247,13 @@ class ComfyList {
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".'
warnDeprecated(
'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".',
source: 'ComfyUI'
}
)
}

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,8 +56,13 @@ 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(
'getSettingValue() defaultValue parameter is deprecated.',
{
suggestion:
'Drop the argument — the default value in the setting definition will be used.',
source: 'ComfySettingsDialog'
}
)
}
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,10 @@ 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('node.setSizeForImage is deprecated and has no effect.', {
suggestion: 'Remove the call.',
source: 'litegraphService'
})
}
node.prototype.onDrawBackground = function () {
updatePreviews(this)

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,65 @@ 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({
message: 'Use of defaultInput on required input m:N:x.',
source: 'nodeDef'
})
})
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({
message: 'Use of defaultInput on optional input m:N:y.',
source: 'nodeDef'
})
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,8 +114,13 @@ 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(
`Use of defaultInput on required input ${nodeDef.python_module}:${nodeDef.name}:${name}.`,
{
suggestion:
'Drop the defaultInput option — required sockets are always present.',
source: 'nodeDef'
}
)
}
}
@@ -124,8 +130,12 @@ 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(
`Use of defaultInput on optional input ${nodeDef.python_module}:${nodeDef.name}:${name}.`,
{
suggestion: 'Replace with forceInput.',
source: 'nodeDef'
}
)
inputOptions.forceInput = true
}

View File

@@ -5,12 +5,21 @@ 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,
mockInstallLiteGraphBridge,
mockBackfillServerDeprecations
} = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn(),
mockUnregisterCommand: vi.fn(),
mockInstallLiteGraphBridge: vi.fn(),
mockBackfillServerDeprecations: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
@@ -21,6 +30,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand,
unregisterCommand: mockUnregisterCommand,
commands: []
})
}))
@@ -54,6 +64,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',
@@ -93,12 +113,18 @@ vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
})
}))
vi.mock('@/platform/dev/installLiteGraphDeprecationBridge', () => ({
installLiteGraphDeprecationBridge: mockInstallLiteGraphBridge
}))
vi.mock('@/platform/dev/backfillServerDeprecations', () => ({
backfillServerDeprecations: mockBackfillServerDeprecations
}))
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 +186,105 @@ 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)
})
it('installs the LiteGraph bridge and backfills server logs at boot regardless of DevMode', () => {
mockGetSetting.mockImplementation(() => false)
useSidebarTabStore().registerCoreSidebarTabs()
expect(mockInstallLiteGraphBridge).toHaveBeenCalledTimes(1)
expect(mockBackfillServerDeprecations).toHaveBeenCalledTimes(1)
})
it('does not re-install the bridge or re-backfill when DevMode flips on later', async () => {
const devMode = ref(false)
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.DevMode' ? devMode.value : undefined
)
useSidebarTabStore().registerCoreSidebarTabs()
devMode.value = true
await nextTick()
expect(mockInstallLiteGraphBridge).toHaveBeenCalledTimes(1)
expect(mockBackfillServerDeprecations).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,10 +2,16 @@ 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'
import { t, te } from '@/i18n'
import { installLiteGraphDeprecationBridge } from '@/platform/dev/installLiteGraphDeprecationBridge'
import { backfillServerDeprecations } from '@/platform/dev/backfillServerDeprecations'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppsSidebarTab } from '@/platform/workflow/management/composables/useAppsSidebarTab'
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
@@ -53,7 +59,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 +113,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
if (activeSidebarTabId.value === id) {
activeSidebarTabId.value = null
}
useCommandStore().unregisterCommand(`Workspace.ToggleSidebarTab.${id}`)
}
}
@@ -114,6 +122,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 +134,33 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
unregisterSidebarTab(jobHistoryTabId)
}
}
syncJobHistoryTab(settingStore.get('Comfy.Queue.QPOV2'))
watch(
() => settingStore.get('Comfy.Queue.QPOV2'),
(enabled) => syncJobHistoryTab(enabled)
)
// Capture warnings regardless of DevMode so a user toggling it on later
// sees the full session history. The sidebar tab itself stays gated.
installLiteGraphDeprecationBridge()
void backfillServerDeprecations()
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

@@ -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,42 @@
import { describe, expect, it, vi } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const WEEK = 7 * DAY
const MONTH = 30 * DAY
const YEAR = 365 * DAY
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 - 1)).toBe('g.relativeTime.now')
})
it('picks the largest unit that fits', () => {
expect(formatRelativeTime(t, MINUTE)).toBe('g.relativeTime.minutesAgo:1')
expect(formatRelativeTime(t, 59 * MINUTE)).toBe(
'g.relativeTime.minutesAgo:59'
)
expect(formatRelativeTime(t, HOUR)).toBe('g.relativeTime.hoursAgo:1')
expect(formatRelativeTime(t, 23 * HOUR)).toBe('g.relativeTime.hoursAgo:23')
expect(formatRelativeTime(t, DAY)).toBe('g.relativeTime.daysAgo:1')
expect(formatRelativeTime(t, WEEK)).toBe('g.relativeTime.weeksAgo:1')
expect(formatRelativeTime(t, MONTH)).toBe('g.relativeTime.monthsAgo:1')
expect(formatRelativeTime(t, YEAR)).toBe('g.relativeTime.yearsAgo:1')
})
it('passes the integer count to the translator', () => {
const spy = vi.fn(t)
formatRelativeTime(spy, 5 * MINUTE + 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')
}