From 0288ea5b39ad192eafb3052eaeb696900e2bba16 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:47:53 +0100 Subject: [PATCH 1/3] feat: add setMany to settingStore for batch setting updates (#8767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `setMany()` method to `settingStore` for updating multiple settings in a single API call via the existing `storeSettings` endpoint - Extracts shared setting-apply logic (`applySettingLocally`) to reduce duplication between `set()` and `setMany()` - Migrates all call sites where multiple settings were updated sequentially to use `setMany()` ## Call sites updated - `releaseStore.ts` — `handleSkipRelease`, `handleShowChangelog`, `handleWhatsNewSeen` (3 settings each) - `keybindingService.ts` — `persistUserKeybindings` (2 settings) - `coreSettings.ts` — `NavigationMode.onChange` (2 settings) ## Test plan - [x] Unit tests for `setMany` (batch update, skip unchanged, no-op when unchanged) - [x] Updated `releaseStore.test.ts` assertions to verify `setMany` usage - [x] Updated `useCoreCommands.test.ts` mock to include `setMany` - [x] All existing tests pass - [x] `pnpm typecheck`, `pnpm lint`, `pnpm format` pass Fixes #1079 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8767-feat-add-setMany-to-settingStore-for-batch-setting-updates-3036d73d36508161b8b6d298e1be1b7a) by [Unito](https://www.unito.io) --- browser_tests/tests/dialog.spec.ts | 5 +- src/composables/useCoreCommands.test.ts | 1 + src/platform/keybindings/keybindingService.ts | 16 ++-- .../settings/constants/coreSettings.ts | 14 ++-- src/platform/settings/settingStore.test.ts | 82 ++++++++++++++++++- src/platform/settings/settingStore.ts | 50 +++++++++-- .../updates/common/releaseStore.test.ts | 54 ++++-------- src/platform/updates/common/releaseStore.ts | 24 ++++-- src/scripts/api.ts | 2 +- 9 files changed, 178 insertions(+), 70 deletions(-) diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index fddbfbc708..c954177d63 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -298,7 +298,10 @@ test.describe('Settings', () => { await input.press('Alt+n') const requestPromise = comfyPage.page.waitForRequest( - '**/api/settings/Comfy.Keybinding.NewBindings' + (req) => + req.url().includes('/api/settings') && + !req.url().includes('/api/settings/') && + req.method() === 'POST' ) // Save keybinding diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index a02fb0414f..925b0aac4e 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -197,6 +197,7 @@ describe('useCoreCommands', () => { addSetting: vi.fn(), load: vi.fn(), set: vi.fn(), + setMany: vi.fn(), exists: vi.fn(), getDefaultValue: vi.fn(), isReady: true, diff --git a/src/platform/keybindings/keybindingService.ts b/src/platform/keybindings/keybindingService.ts index f8c822273e..6b6047dec2 100644 --- a/src/platform/keybindings/keybindingService.ts +++ b/src/platform/keybindings/keybindingService.ts @@ -137,14 +137,14 @@ export function useKeybindingService() { } async function persistUserKeybindings() { - await settingStore.set( - 'Comfy.Keybinding.NewBindings', - Object.values(keybindingStore.getUserKeybindings()) - ) - await settingStore.set( - 'Comfy.Keybinding.UnsetBindings', - Object.values(keybindingStore.getUserUnsetKeybindings()) - ) + await settingStore.setMany({ + 'Comfy.Keybinding.NewBindings': Object.values( + keybindingStore.getUserKeybindings() + ), + 'Comfy.Keybinding.UnsetBindings': Object.values( + keybindingStore.getUserUnsetKeybindings() + ) + }) } return { diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 38e635cb9b..e75e6445c6 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -170,13 +170,15 @@ export const CORE_SETTINGS: SettingParams[] = [ const settingStore = useSettingStore() if (newValue === 'standard') { - // Update related settings to match standard mode - select + panning - await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'select') - await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'panning') + await settingStore.setMany({ + 'Comfy.Canvas.LeftMouseClickBehavior': 'select', + 'Comfy.Canvas.MouseWheelScroll': 'panning' + }) } else if (newValue === 'legacy') { - // Update related settings to match legacy mode - panning + zoom - await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'panning') - await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'zoom') + await settingStore.setMany({ + 'Comfy.Canvas.LeftMouseClickBehavior': 'panning', + 'Comfy.Canvas.MouseWheelScroll': 'zoom' + }) } } }, diff --git a/src/platform/settings/settingStore.test.ts b/src/platform/settings/settingStore.test.ts index 681d13e886..6117441950 100644 --- a/src/platform/settings/settingStore.test.ts +++ b/src/platform/settings/settingStore.test.ts @@ -15,7 +15,8 @@ import { app } from '@/scripts/app' vi.mock('@/scripts/api', () => ({ api: { getSettings: vi.fn(), - storeSetting: vi.fn() + storeSetting: vi.fn(), + storeSettings: vi.fn() } })) @@ -503,6 +504,85 @@ describe('useSettingStore', () => { }) }) }) + + describe('setMany', () => { + it('should set multiple values and make a single API call', async () => { + const onChange1 = vi.fn() + const onChange2 = vi.fn() + store.addSetting({ + id: 'Comfy.Release.Version', + name: 'Release Version', + type: 'hidden', + defaultValue: '', + onChange: onChange1 + }) + store.addSetting({ + id: 'Comfy.Release.Status', + name: 'Release Status', + type: 'hidden', + defaultValue: 'skipped', + onChange: onChange2 + }) + vi.clearAllMocks() + + await store.setMany({ + 'Comfy.Release.Version': '1.0.0', + 'Comfy.Release.Status': 'changelog seen' + }) + + expect(store.get('Comfy.Release.Version')).toBe('1.0.0') + expect(store.get('Comfy.Release.Status')).toBe('changelog seen') + expect(onChange1).toHaveBeenCalledWith('1.0.0', '') + expect(onChange2).toHaveBeenCalledWith('changelog seen', 'skipped') + expect(api.storeSettings).toHaveBeenCalledTimes(1) + expect(api.storeSettings).toHaveBeenCalledWith({ + 'Comfy.Release.Version': '1.0.0', + 'Comfy.Release.Status': 'changelog seen' + }) + expect(api.storeSetting).not.toHaveBeenCalled() + }) + + it('should skip unchanged values', async () => { + store.addSetting({ + id: 'Comfy.Release.Version', + name: 'Release Version', + type: 'hidden', + defaultValue: '' + }) + store.addSetting({ + id: 'Comfy.Release.Status', + name: 'Release Status', + type: 'hidden', + defaultValue: 'skipped' + }) + await store.set('Comfy.Release.Version', 'existing') + vi.clearAllMocks() + + await store.setMany({ + 'Comfy.Release.Version': 'existing', + 'Comfy.Release.Status': 'changelog seen' + }) + + expect(api.storeSettings).toHaveBeenCalledWith({ + 'Comfy.Release.Status': 'changelog seen' + }) + }) + + it('should not call API when all values are unchanged', async () => { + store.addSetting({ + id: 'Comfy.Release.Version', + name: 'Release Version', + type: 'hidden', + defaultValue: '' + }) + await store.set('Comfy.Release.Version', 'existing') + vi.clearAllMocks() + + await store.setMany({ 'Comfy.Release.Version': 'existing' }) + + expect(api.storeSettings).not.toHaveBeenCalled() + }) + }) }) describe('getSettingInfo', () => { diff --git a/src/platform/settings/settingStore.ts b/src/platform/settings/settingStore.ts index 1627f70c10..3bfa58b4e6 100644 --- a/src/platform/settings/settingStore.ts +++ b/src/platform/settings/settingStore.ts @@ -92,23 +92,58 @@ export const useSettingStore = defineStore('setting', () => { } /** - * Set a setting value. - * @param key - The key of the setting to set. - * @param value - The value to set. + * Apply a setting value locally: clone, migrate, fire onChange, and + * update the in-memory store. Returns the migrated value, or + * `undefined` when the value is unchanged and was skipped. */ - async function set(key: K, value: Settings[K]) { - // Clone the incoming value to prevent external mutations + function applySettingLocally( + key: K, + value: Settings[K] + ): Settings[K] | undefined { const clonedValue = _.cloneDeep(value) const newValue = tryMigrateDeprecatedValue( settingsById.value[key], clonedValue ) const oldValue = get(key) - if (newValue === oldValue) return + if (newValue === oldValue) return undefined onChange(settingsById.value[key], newValue, oldValue) settingValues.value[key] = newValue - await api.storeSetting(key, newValue) + return newValue as Settings[K] + } + + /** + * Set a setting value. + * @param key - The key of the setting to set. + * @param value - The value to set. + */ + async function set(key: K, value: Settings[K]) { + const applied = applySettingLocally(key, value) + if (applied === undefined) return + await api.storeSetting(key, applied) + } + + /** + * Set multiple setting values in a single API call. + * @param settings - A partial settings object with key-value pairs to set. + */ + async function setMany(settings: Partial) { + const updatedSettings: Partial = {} + + for (const key of Object.keys(settings) as (keyof Settings)[]) { + const applied = applySettingLocally( + key, + settings[key] as Settings[typeof key] + ) + if (applied !== undefined) { + updatedSettings[key] = applied + } + } + + if (Object.keys(updatedSettings).length > 0) { + await api.storeSettings(updatedSettings) + } } /** @@ -271,6 +306,7 @@ export const useSettingStore = defineStore('setting', () => { load, addSetting, set, + setMany, get, exists, getDefaultValue diff --git a/src/platform/updates/common/releaseStore.test.ts b/src/platform/updates/common/releaseStore.test.ts index 754ded17d9..6f045003ff 100644 --- a/src/platform/updates/common/releaseStore.test.ts +++ b/src/platform/updates/common/releaseStore.test.ts @@ -46,8 +46,9 @@ vi.mock('@/platform/settings/settingStore', () => { return null }) const set = vi.fn() + const setMany = vi.fn() return { - useSettingStore: () => ({ get, set }) + useSettingStore: () => ({ get, set, setMany }) } }) @@ -534,18 +535,11 @@ describe('useReleaseStore', () => { const settingStore = useSettingStore() await store.handleSkipRelease('1.2.0') - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Version', - '1.2.0' - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Status', - 'skipped' - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Timestamp', - expect.any(Number) - ) + expect(settingStore.setMany).toHaveBeenCalledWith({ + 'Comfy.Release.Version': '1.2.0', + 'Comfy.Release.Status': 'skipped', + 'Comfy.Release.Timestamp': expect.any(Number) + }) }) it('should handle show changelog', async () => { @@ -554,18 +548,11 @@ describe('useReleaseStore', () => { const settingStore = useSettingStore() await store.handleShowChangelog('1.2.0') - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Version', - '1.2.0' - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Status', - 'changelog seen' - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Timestamp', - expect.any(Number) - ) + expect(settingStore.setMany).toHaveBeenCalledWith({ + 'Comfy.Release.Version': '1.2.0', + 'Comfy.Release.Status': 'changelog seen', + 'Comfy.Release.Timestamp': expect.any(Number) + }) }) it('should handle whats new seen', async () => { @@ -574,18 +561,11 @@ describe('useReleaseStore', () => { const settingStore = useSettingStore() await store.handleWhatsNewSeen('1.2.0') - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Version', - '1.2.0' - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Status', - "what's new seen" - ) - expect(settingStore.set).toHaveBeenCalledWith( - 'Comfy.Release.Timestamp', - expect.any(Number) - ) + expect(settingStore.setMany).toHaveBeenCalledWith({ + 'Comfy.Release.Version': '1.2.0', + 'Comfy.Release.Status': "what's new seen", + 'Comfy.Release.Timestamp': expect.any(Number) + }) }) }) diff --git a/src/platform/updates/common/releaseStore.ts b/src/platform/updates/common/releaseStore.ts index d6581b26f3..fa268ed8c3 100644 --- a/src/platform/updates/common/releaseStore.ts +++ b/src/platform/updates/common/releaseStore.ts @@ -208,9 +208,11 @@ export const useReleaseStore = defineStore('release', () => { return } - await settingStore.set('Comfy.Release.Version', version) - await settingStore.set('Comfy.Release.Status', 'skipped') - await settingStore.set('Comfy.Release.Timestamp', Date.now()) + await settingStore.setMany({ + 'Comfy.Release.Version': version, + 'Comfy.Release.Status': 'skipped', + 'Comfy.Release.Timestamp': Date.now() + }) } async function handleShowChangelog(version: string): Promise { @@ -218,9 +220,11 @@ export const useReleaseStore = defineStore('release', () => { return } - await settingStore.set('Comfy.Release.Version', version) - await settingStore.set('Comfy.Release.Status', 'changelog seen') - await settingStore.set('Comfy.Release.Timestamp', Date.now()) + await settingStore.setMany({ + 'Comfy.Release.Version': version, + 'Comfy.Release.Status': 'changelog seen', + 'Comfy.Release.Timestamp': Date.now() + }) } async function handleWhatsNewSeen(version: string): Promise { @@ -228,9 +232,11 @@ export const useReleaseStore = defineStore('release', () => { return } - await settingStore.set('Comfy.Release.Version', version) - await settingStore.set('Comfy.Release.Status', "what's new seen") - await settingStore.set('Comfy.Release.Timestamp', Date.now()) + await settingStore.setMany({ + 'Comfy.Release.Version': version, + 'Comfy.Release.Status': "what's new seen", + 'Comfy.Release.Timestamp': Date.now() + }) } // Fetch releases from API diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 98c7008a7a..3bacedd8e3 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -1047,7 +1047,7 @@ export class ComfyApi extends EventTarget { /** * Stores a dictionary of settings for the current user */ - async storeSettings(settings: Settings) { + async storeSettings(settings: Partial) { return this.fetchApi(`/settings`, { method: 'POST', body: JSON.stringify(settings) From 9dde4e7bc716cade57adbb17c5a167efef3da301 Mon Sep 17 00:00:00 2001 From: Simula_r <18093452+simula-r@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:11:35 -0800 Subject: [PATCH 2/3] feat: sort workspaces (#8770) ## Summary Sort workspaces so that the personal workspace appears first, followed by the rest in ascending order (oldest first) by created_at / joined_at. ## Changes - **What**: teamWorkspaceStore.ts, teamWorkspaceStore.test.ts - **Breaking**: - **Dependencies**: --- .../auth/workspace/useWorkspaceSwitch.test.ts | 4 +- src/platform/workspace/api/workspaceApi.ts | 2 + .../stores/teamWorkspaceStore.test.ts | 76 +++++++++++++++++-- .../workspace/stores/teamWorkspaceStore.ts | 22 +++++- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/platform/auth/workspace/useWorkspaceSwitch.test.ts b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts index 914c8bf9af..6cc03049dd 100644 --- a/src/platform/auth/workspace/useWorkspaceSwitch.test.ts +++ b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts @@ -53,7 +53,9 @@ describe('useWorkspaceSwitch', () => { id: 'workspace-1', name: 'Test Workspace', type: 'personal', - role: 'owner' + role: 'owner', + created_at: '2026-01-01T00:00:00Z', + joined_at: '2026-01-01T00:00:00Z' } mockModifiedWorkflows.length = 0 }) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index caf07db402..a0361ddfab 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -11,6 +11,8 @@ interface Workspace { id: string name: string type: WorkspaceType + created_at: string + joined_at: string } export interface WorkspaceWithRole extends Workspace { diff --git a/src/platform/workspace/stores/teamWorkspaceStore.test.ts b/src/platform/workspace/stores/teamWorkspaceStore.test.ts index 92b7127874..2c29bfd3c8 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.test.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.test.ts @@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useTeamWorkspaceStore } from './teamWorkspaceStore' +import { sortWorkspaces, useTeamWorkspaceStore } from './teamWorkspaceStore' // Mock workspaceAuthStore const mockWorkspaceAuthStore = vi.hoisted(() => ({ @@ -93,21 +93,27 @@ const mockPersonalWorkspace = { id: 'ws-personal-123', name: 'Personal', type: 'personal' as const, - role: 'owner' as const + role: 'owner' as const, + created_at: '2026-01-01T00:00:00Z', + joined_at: '2026-01-01T00:00:00Z' } const mockTeamWorkspace = { id: 'ws-team-456', name: 'Team Alpha', type: 'team' as const, - role: 'owner' as const + role: 'owner' as const, + created_at: '2026-02-01T00:00:00Z', + joined_at: '2026-02-01T00:00:00Z' } const mockMemberWorkspace = { id: 'ws-team-789', name: 'Team Beta', type: 'team' as const, - role: 'member' as const + role: 'member' as const, + created_at: '2026-03-01T00:00:00Z', + joined_at: '2026-03-01T00:00:00Z' } describe('useTeamWorkspaceStore', () => { @@ -309,7 +315,9 @@ describe('useTeamWorkspaceStore', () => { id: 'ws-new-999', name: 'New Workspace', type: 'team' as const, - role: 'member' as const + role: 'member' as const, + created_at: '2026-04-01T00:00:00Z', + joined_at: '2026-04-01T00:00:00Z' } mockWorkspaceApi.list @@ -347,7 +355,9 @@ describe('useTeamWorkspaceStore', () => { id: 'ws-new-created', name: 'Created Workspace', type: 'team' as const, - role: 'owner' as const + role: 'owner' as const, + created_at: '2026-05-01T00:00:00Z', + joined_at: '2026-05-01T00:00:00Z' } mockWorkspaceApi.create.mockResolvedValue(newWorkspace) @@ -386,7 +396,9 @@ describe('useTeamWorkspaceStore', () => { id: 'ws-new', name: 'New Workspace', type: 'team', - role: 'owner' + role: 'owner', + created_at: '2026-06-01T00:00:00Z', + joined_at: '2026-06-01T00:00:00Z' }) await resultPromise }) @@ -572,7 +584,9 @@ describe('useTeamWorkspaceStore', () => { id: `ws-owned-${i}`, name: `Owned ${i}`, type: 'team' as const, - role: 'owner' as const + role: 'owner' as const, + created_at: `2026-${String(i + 1).padStart(2, '0')}-01T00:00:00Z`, + joined_at: `2026-${String(i + 1).padStart(2, '0')}-01T00:00:00Z` })) mockWorkspaceApi.list.mockResolvedValue({ @@ -916,3 +930,49 @@ describe('useTeamWorkspaceStore', () => { }) }) }) + +describe('sortWorkspaces', () => { + it('places personal first, then sorts ascending by created_at for owners and joined_at for members', () => { + const input = [ + { + created_at: '2026-06-01T00:00:00Z', + id: 'w-team-new-owner', + joined_at: '2026-01-01T00:00:00Z', + name: 'Newest Owner Team', + role: 'owner' as const, + type: 'team' as const + }, + { + created_at: '2026-12-01T00:00:00Z', + id: 'w-personal', + joined_at: '2026-12-01T00:00:00Z', + name: 'Personal Workspace', + role: 'owner' as const, + type: 'personal' as const + }, + { + created_at: '2026-01-01T00:00:00Z', + id: 'w-team-member', + joined_at: '2026-04-01T00:00:00Z', + name: 'Member Team', + role: 'member' as const, + type: 'team' as const + }, + { + created_at: '2026-02-01T00:00:00Z', + id: 'w-team-old-owner', + joined_at: '2026-09-01T00:00:00Z', + name: 'Oldest Owner Team', + role: 'owner' as const, + type: 'team' as const + } + ] + + expect(sortWorkspaces(input).map((w) => w.id)).toEqual([ + 'w-personal', + 'w-team-old-owner', + 'w-team-member', + 'w-team-new-owner' + ]) + }) +}) diff --git a/src/platform/workspace/stores/teamWorkspaceStore.ts b/src/platform/workspace/stores/teamWorkspaceStore.ts index b6a6dbd465..acc69acad3 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.ts @@ -76,6 +76,16 @@ function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState { } } +export function sortWorkspaces(list: T[]): T[] { + return [...list].sort((a, b) => { + if (a.type === 'personal') return -1 + if (b.type === 'personal') return 1 + const dateA = a.role === 'owner' ? a.created_at : a.joined_at + const dateB = b.role === 'owner' ? b.created_at : b.joined_at + return dateA.localeCompare(dateB) + }) +} + function getLastWorkspaceId(): string | null { try { return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID) @@ -205,7 +215,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => { if (hasValidSession && workspaceAuthStore.currentWorkspace) { // Valid session exists - fetch workspace list and verify access const response = await workspaceApi.list() - workspaces.value = response.workspaces.map(createWorkspaceState) + workspaces.value = sortWorkspaces( + response.workspaces.map(createWorkspaceState) + ) if (workspaces.value.length === 0) { throw new Error('No workspaces available') @@ -247,7 +259,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => { // 2. No valid session - fetch workspaces and pick default const response = await workspaceApi.list() - workspaces.value = response.workspaces.map(createWorkspaceState) + workspaces.value = sortWorkspaces( + response.workspaces.map(createWorkspaceState) + ) if (workspaces.value.length === 0) { throw new Error('No workspaces available') @@ -315,7 +329,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => { isFetchingWorkspaces.value = true try { const response = await workspaceApi.list() - workspaces.value = response.workspaces.map(createWorkspaceState) + workspaces.value = sortWorkspaces( + response.workspaces.map(createWorkspaceState) + ) } finally { isFetchingWorkspaces.value = false } From 581452d3121e19fbfb6a5f48d8fbbf342432fe02 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Tue, 10 Feb 2026 12:31:09 -0800 Subject: [PATCH 3/3] Austin/fix move subgraph input (#8777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, moving a subgraph input link and re-attaching to the same input slot would result in an invalid link ![broken-link](https://github.com/user-attachments/assets/085a0a6f-281d-4e06-be58-e5bdc873f1d5) This occurred because: - A new link is created to which overwrites the target `input.link` - The previous link is then disconnected, which clears `input.link` This is solved by instead returning early if the target is the same as the existing link. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8777-Austin-fix-move-subgraph-input-3036d73d365081318de3cccb926f7fe7) by [Unito](https://www.unito.io) --- ...nkConnectorSubgraphInputValidation.test.ts | 22 +++++++++++++++++++ .../src/canvas/ToInputFromIoNodeLink.ts | 6 +++++ 2 files changed, 28 insertions(+) diff --git a/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts b/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts index 8c29220056..931bc1e427 100644 --- a/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnectorSubgraphInputValidation.test.ts @@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => { expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1) expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1) }) + it('should allow reconnection to same target', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'number_input', type: 'number' }] + }) + + const node = new LGraphNode('TargetNode') + node.addInput('number_in', 'number') + subgraph.add(node) + + const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const renderLink = new ToInputFromIoNodeLink( + subgraph, + subgraph.inputNode, + subgraph.inputNode.slots[0], + undefined, + LinkDirection.CENTER, + link + ) + renderLink.connectToInput(node, node.inputs[0], connector.events) + expect(node.inputs[0].link).not.toBeNull() + }) }) describe('MovingOutputLink validation', () => { diff --git a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts index 073c777f1f..7e5880212b 100644 --- a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts +++ b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts @@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink { events: CustomEventTarget ) { const { fromSlot, fromReroute, existingLink } = this + if ( + existingLink && + node.id === existingLink.target_id && + node.inputs[existingLink.target_slot] === input + ) + return const newLink = fromSlot.connect(input, node, fromReroute?.id)