[feat] Add node replacement store and types (#8364)

## Summary
Add infrastructure for automatic node replacement feature that allows
missing/deprecated nodes to be replaced with their newer equivalents.

## Changes
- **Types**: `NodeReplacement`, `NodeReplacementResponse` types matching
backend API spec (PR #12014)
- **Service**: `fetchNodeReplacements()` API wrapper
- **Store**: `useNodeReplacementStore` with `getReplacementFor()`,
`hasReplacement()`, `isEnabled()`
- **Setting**: `Comfy.NodeReplacement.Enabled` toggle (experimental)
- **Tests**: 11 unit tests covering store functionality

## Related
- Backend PR: Comfy-Org/ComfyUI#12014

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8364-feat-Add-node-replacement-store-and-types-2f66d73d3650816bb771c9cc6a8e1774)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Automatic node replacement is now available, allowing missing nodes to
be replaced with their newer equivalents when replacement mappings
exist.
* Added "Enable automatic node replacement" experimental setting in
Workflow preferences (enabled by default).
  * Replacement data is loaded during app initialization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jin Yi
2026-02-03 14:15:05 +09:00
committed by GitHub
parent cc10684c19
commit 5847071bc1
7 changed files with 342 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import type { NodeReplacementResponse } from './types'
import { api } from '@/scripts/api'
export async function fetchNodeReplacements(): Promise<NodeReplacementResponse> {
const response = await api.fetchApi('/node_replacements')
if (!response.ok) {
throw new Error(
`Failed to fetch node replacements: ${response.status} ${response.statusText}`
)
}
return response.json()
}

View File

@@ -0,0 +1,240 @@
import type { NodeReplacementResponse } from './types'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingStore } from '@/platform/settings/settingStore'
import { fetchNodeReplacements } from './nodeReplacementService'
import { useNodeReplacementStore } from './nodeReplacementStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn()
}))
vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
function mockSettingStore(enabled: boolean) {
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn().mockImplementation((key: string) => {
if (key === 'Comfy.NodeReplacement.Enabled') {
return enabled
}
return false
})
})
}
function createStore(enabled = true) {
setActivePinia(createPinia())
mockSettingStore(enabled)
return useNodeReplacementStore()
}
describe('useNodeReplacementStore', () => {
let store: ReturnType<typeof useNodeReplacementStore>
beforeEach(() => {
vi.clearAllMocks()
store = createStore(true)
})
it('should initialize with empty replacements', () => {
expect(store.replacements).toEqual({})
expect(store.isLoaded).toBe(false)
})
describe('getReplacementFor', () => {
it('should return first replacement for existing node type', () => {
store.replacements = {
OldNode: [
{
new_node_id: 'NewNodeA',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
},
{
new_node_id: 'NewNodeB',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
const result = store.getReplacementFor('OldNode')
expect(result).not.toBeNull()
expect(result?.new_node_id).toBe('NewNodeA')
})
it('should return null for non-existing node type', () => {
store.replacements = {
OldNode: [
{
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
const result = store.getReplacementFor('NonExistentNode')
expect(result).toBeNull()
})
it('should return null for empty replacement array', () => {
store.replacements = {
OldNode: []
}
const result = store.getReplacementFor('OldNode')
expect(result).toBeNull()
})
it('should return null when feature is disabled', () => {
store = createStore(false)
store.replacements = {
OldNode: [
{
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
const result = store.getReplacementFor('OldNode')
expect(result).toBeNull()
})
})
describe('hasReplacement', () => {
it('should return true when replacement exists', () => {
store.replacements = {
OldNode: [
{
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
expect(store.hasReplacement('OldNode')).toBe(true)
})
it('should return false when node type does not exist', () => {
store.replacements = {}
expect(store.hasReplacement('NonExistentNode')).toBe(false)
})
it('should return false when replacement array is empty', () => {
store.replacements = {
OldNode: []
}
expect(store.hasReplacement('OldNode')).toBe(false)
})
it('should return false when feature is disabled', () => {
store = createStore(false)
store.replacements = {
OldNode: [
{
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
expect(store.hasReplacement('OldNode')).toBe(false)
})
})
describe('isEnabled', () => {
it('should return true when setting is enabled', () => {
expect(store.isEnabled).toBe(true)
})
it('should return false when setting is disabled', () => {
store = createStore(false)
expect(store.isEnabled).toBe(false)
})
})
describe('load', () => {
const mockReplacements: NodeReplacementResponse = {
OldNode: [
{
new_node_id: 'NewNode',
old_node_id: 'OldNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
]
}
beforeEach(() => {
vi.mocked(fetchNodeReplacements).mockReset()
})
it('should fetch and assign replacements on successful load', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore()
await store.load()
expect(fetchNodeReplacements).toHaveBeenCalledOnce()
expect(store.replacements).toEqual(mockReplacements)
expect(store.isLoaded).toBe(true)
})
it('should log error but not throw when fetch fails', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const error = new Error('Network error')
vi.mocked(fetchNodeReplacements).mockRejectedValue(error)
store = createStore()
await expect(store.load()).resolves.toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load node replacements:',
error
)
expect(store.isLoaded).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should not re-fetch when called twice', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore()
await store.load()
await store.load()
expect(fetchNodeReplacements).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,46 @@
import type { NodeReplacement, NodeReplacementResponse } from './types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { fetchNodeReplacements } from './nodeReplacementService'
export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
const settingStore = useSettingStore()
const replacements = ref<NodeReplacementResponse>({})
const isLoaded = ref(false)
const isEnabled = computed(() =>
settingStore.get('Comfy.NodeReplacement.Enabled')
)
async function load() {
if (isLoaded.value) return
try {
replacements.value = await fetchNodeReplacements()
isLoaded.value = true
} catch (error) {
console.error('Failed to load node replacements:', error)
}
}
function getReplacementFor(nodeType: string): NodeReplacement | null {
if (!isEnabled.value) return null
return replacements.value[nodeType]?.[0] ?? null
}
function hasReplacement(nodeType: string): boolean {
if (!isEnabled.value) return false
return !!replacements.value[nodeType]?.length
}
return {
replacements,
isLoaded,
load,
isEnabled,
getReplacementFor,
hasReplacement
}
})

View File

@@ -0,0 +1,29 @@
interface InputAssignOldId {
assign_type: 'old_id'
old_id: string
}
interface InputAssignSetValue {
assign_type: 'set_value'
value: unknown
}
interface InputMap {
new_id: string
assign: InputAssignOldId | InputAssignSetValue
}
interface OutputMap {
new_idx: number
old_idx: number
}
export interface NodeReplacement {
new_node_id: string
old_node_id: string
old_widget_ids: string[] | null
input_mapping: InputMap[] | null
output_mapping: OutputMap[] | null
}
export type NodeReplacementResponse = Record<string, NodeReplacement[]>