From cbdc7d030f15e2f279bc869c5bb701e51e5cdc32 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Mon, 2 Feb 2026 19:05:28 -0800 Subject: [PATCH 01/10] feat: code splitting optimization - reduce initial bundle by 36% (#8542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reduces initial module preload from 12.94 MB to 8.24 MB (-4.7 MB, -36%). ## Changes - Split vendor chunks for better cache isolation (firebase, sentry, i18n, zod, etc.) - Exclude heavy optional chunks from initial preload: THREE.js, xterm, tiptap, chart.js, yjs - Lazy load 16 dialog components in dialogService - Add \ endor-yjs\ chunk for CRDT library ## Metrics | Metric | Before | After | |--------|--------|-------| | Initial preload | 12.94 MB | 8.24 MB | | vendor-other | 4,006 KB | 2,156 KB | | dialogService | 1,860 KB | 1,304 KB | All excluded chunks still load on-demand when their features are used. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8542-feat-code-splitting-optimization-reduce-initial-bundle-by-36-2fb6d73d36508146aaf7fdaed3274033) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp Co-authored-by: github-actions --- ...le-settings-dialog-mobile-chrome-linux.png | Bin 16869 -> 16862 bytes src/components/load3d/Load3dViewerContent.vue | 3 +- .../sidebar/tabs/AssetsSidebarTab.vue | 4 +- src/composables/useCoreCommands.ts | 4 +- src/composables/useHelpCenter.ts | 2 +- src/composables/useLoad3dDrag.ts | 2 +- src/extensions/core/index.ts | 6 +- src/extensions/core/load3d/constants.ts | 16 +++ src/extensions/core/load3d/interfaces.ts | 30 ++-- src/extensions/core/load3dLazy.ts | 53 +++++++ .../composables/useSubscriptionActions.ts | 2 +- .../extensions/linearMode/LinearPreview.vue | 3 +- src/services/dialogService.ts | 132 +++++++++++++---- src/services/load3dService.ts | 125 ++++++++++++++-- .../composables/useImportFailedDetection.ts | 2 +- .../manager/composables/useManagerState.ts | 4 +- vite.config.mts | 135 ++++++++++++++---- 17 files changed, 423 insertions(+), 100 deletions(-) create mode 100644 src/extensions/core/load3d/constants.ts create mode 100644 src/extensions/core/load3dLazy.ts diff --git a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png index 6bb4ac6e39785c1f46c8d9945339c1a2c268af28..17de9b03aff4c13f05c805b3badb77cd151c286b 100644 GIT binary patch literal 16862 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%0D{pcEp_VM?3c$x7x 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 literal 16869 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 diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue index b0ae308cb..685af08f8 100644 --- a/src/components/load3d/Load3dViewerContent.vue +++ b/src/components/load3d/Load3dViewerContent.vue @@ -121,8 +121,9 @@ const mutationObserver = ref(null) const isStandaloneMode = !props.node && props.modelUrl +// Use sync version since useLoad3dViewer is already imported (module is loaded) const viewer = props.node - ? useLoad3dService().getOrCreateViewer(toRaw(props.node)) + ? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer) : useLoad3dViewer() const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } = diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 091d951c2..4bede6891 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -219,7 +219,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' -import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue' +// Lazy-loaded to avoid pulling THREE.js into the main bundle +const Load3dViewerContent = () => + import('@/components/load3d/Load3dViewerContent.vue') import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue' import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 19d36f52b..7e895d323 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -580,7 +580,7 @@ export function useCoreCommands(): ComfyCommand[] { versionAdded: '1.3.7', category: 'view-controls' as const, function: () => { - dialogService.showSettingsDialog() + void dialogService.showSettingsDialog() } }, { @@ -829,7 +829,7 @@ export function useCoreCommands(): ComfyCommand[] { menubarLabel: 'About ComfyUI', versionAdded: '1.6.4', function: () => { - dialogService.showSettingsDialog('about') + void dialogService.showSettingsDialog('about') } }, { diff --git a/src/composables/useHelpCenter.ts b/src/composables/useHelpCenter.ts index 3f156119f..cb9dd2700 100644 --- a/src/composables/useHelpCenter.ts +++ b/src/composables/useHelpCenter.ts @@ -72,7 +72,7 @@ export function useHelpCenter( * Show the node conflict dialog with current conflict data */ const showConflictModal = () => { - showNodeConflictDialog({ + void showNodeConflictDialog({ showAfterWhatsNew: true, dialogComponentProps: { onClose: () => { diff --git a/src/composables/useLoad3dDrag.ts b/src/composables/useLoad3dDrag.ts index d7d5ea7f0..87a8292ec 100644 --- a/src/composables/useLoad3dDrag.ts +++ b/src/composables/useLoad3dDrag.ts @@ -1,7 +1,7 @@ import { computed, ref, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue' -import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/interfaces' +import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants' import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 457ef244f..ba8ef73cd 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -11,7 +11,9 @@ import './groupNodeManage' import './groupOptions' import './imageCompare' import './imageCrop' -import './load3d' +// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB) +// The lazy loader triggers loading when a 3D node is used +import './load3dLazy' import './maskeditor' if (!isCloud) { await import('./nodeTemplates') @@ -20,7 +22,7 @@ import './noteNode' import './previewAny' import './rerouteNode' import './saveImageExtraOutput' -import './saveMesh' +// saveMesh is loaded on-demand with load3d (see load3dLazy.ts) import './selectionBorder' import './simpleTouchSupport' import './slotDefaults' diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts new file mode 100644 index 000000000..d74f31855 --- /dev/null +++ b/src/extensions/core/load3d/constants.ts @@ -0,0 +1,16 @@ +/** + * Load3D constants that don't require THREE.js + * This file can be imported without pulling in the entire THREE.js bundle + */ + +export const SUPPORTED_EXTENSIONS = new Set([ + '.gltf', + '.glb', + '.obj', + '.fbx', + '.stl', + '.spz', + '.splat', + '.ply', + '.ksplat' +]) diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 7beb3882a..6563eaa2d 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -1,11 +1,13 @@ -import * as THREE from 'three' -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' -import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' -import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' -import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' -import { type OBJLoader2Parallel } from 'wwobjloader2' +// Use type-only imports to avoid pulling THREE.js into the main bundle +// These imports are erased at compile time and don't create runtime dependencies +import type * as THREE from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' +import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' +import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' +import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader' +import type { OBJLoader2Parallel } from 'wwobjloader2' export type MaterialMode = | 'original' @@ -192,15 +194,3 @@ export interface LoaderManagerInterface { dispose(): void loadModel(url: string, originalFileName?: string): Promise } - -export const SUPPORTED_EXTENSIONS = new Set([ - '.gltf', - '.glb', - '.obj', - '.fbx', - '.stl', - '.spz', - '.splat', - '.ply', - '.ksplat' -]) diff --git a/src/extensions/core/load3dLazy.ts b/src/extensions/core/load3dLazy.ts new file mode 100644 index 000000000..fcb91623a --- /dev/null +++ b/src/extensions/core/load3dLazy.ts @@ -0,0 +1,53 @@ +/** + * Lazy loader for 3D extensions (Load3D, Preview3D, SaveGLB) + * + * This module defers loading of THREE.js (~1.8MB) until a 3D node is actually + * used in a workflow. The heavy imports are only loaded when: + * - A workflow containing 3D nodes is loaded + * - A user adds a 3D node from the node menu + */ + +import { useExtensionService } from '@/services/extensionService' + +const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB']) + +let load3dExtensionsLoaded = false +let load3dExtensionsLoading: Promise | null = null + +/** + * Dynamically load the 3D extensions (and THREE.js) on demand + */ +async function loadLoad3dExtensions(): Promise { + if (load3dExtensionsLoaded) return + + if (load3dExtensionsLoading) { + return load3dExtensionsLoading + } + + load3dExtensionsLoading = (async () => { + // Import both extensions - they will self-register via useExtensionService() + await Promise.all([import('./load3d'), import('./saveMesh')]) + load3dExtensionsLoaded = true + })() + + return load3dExtensionsLoading +} + +/** + * Check if a node type is a 3D node that requires THREE.js + */ +function isLoad3dNodeType(nodeTypeName: string): boolean { + return LOAD3D_NODE_TYPES.has(nodeTypeName) +} + +// Register a lightweight extension that triggers lazy loading +useExtensionService().registerExtension({ + name: 'Comfy.Load3DLazy', + + async beforeRegisterNodeDef(_nodeType, nodeData) { + // When a 3D node type is being registered, load the 3D extensions + if (isLoad3dNodeType(nodeData.name)) { + await loadLoad3dExtensions() + } + } +}) diff --git a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts index 7c09942f0..daddf1b74 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts @@ -24,7 +24,7 @@ export function useSubscriptionActions() { }) const handleAddApiCredits = () => { - dialogService.showTopUpCreditsDialog() + void dialogService.showTopUpCreditsDialog() } const handleMessageSupport = async () => { diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index 378a579dc..dc384bef0 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -11,7 +11,8 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil' import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue' -import Preview3d from '@/renderer/extensions/linearMode/Preview3d.vue' +// Lazy-loaded to avoid pulling THREE.js into the main bundle +const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.vue') import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue' import { getMediaType, diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 69171c895..6cae1c59a 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -1,39 +1,62 @@ import { merge } from 'es-toolkit/compat' import type { Component } from 'vue' -import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue' -import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue' -import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue' -import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue' import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue' import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue' -import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue' import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue' -import SignInContent from '@/components/dialog/content/SignInContent.vue' -import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue' -import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue' -import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue' -import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue' import { t } from '@/i18n' import { useTelemetry } from '@/platform/telemetry' import { isCloud } from '@/platform/distribution/types' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' -import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue' import { useDialogStore } from '@/stores/dialogStore' import type { DialogComponentProps, ShowDialogOptions } from '@/stores/dialogStore' -import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue' -import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue' -import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue' -import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue' -import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue' -import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue' import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes' import type { ComponentAttrs } from 'vue-component-type-helpers' +// Type-only imports for ComponentAttrs inference (no runtime cost) +import type MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue' +import type MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue' + +// Lazy loaders for dialogs - components are loaded on first use +const lazyMissingNodesContent = () => + import('@/components/dialog/content/MissingNodesContent.vue') +const lazyMissingNodesHeader = () => + import('@/components/dialog/content/MissingNodesHeader.vue') +const lazyMissingNodesFooter = () => + import('@/components/dialog/content/MissingNodesFooter.vue') +const lazyMissingModelsWarning = () => + import('@/components/dialog/content/MissingModelsWarning.vue') +const lazyApiNodesSignInContent = () => + import('@/components/dialog/content/ApiNodesSignInContent.vue') +const lazySignInContent = () => + import('@/components/dialog/content/SignInContent.vue') +const lazyTopUpCreditsDialogContent = () => + import('@/components/dialog/content/TopUpCreditsDialogContent.vue') +const lazyUpdatePasswordContent = () => + import('@/components/dialog/content/UpdatePasswordContent.vue') +const lazyComfyOrgHeader = () => + import('@/components/dialog/header/ComfyOrgHeader.vue') +const lazySettingDialogHeader = () => + import('@/components/dialog/header/SettingDialogHeader.vue') +const lazySettingDialogContent = () => + import('@/platform/settings/components/SettingDialogContent.vue') +const lazyImportFailedNodeContent = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue') +const lazyImportFailedNodeHeader = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue') +const lazyImportFailedNodeFooter = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue') +const lazyNodeConflictDialogContent = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue') +const lazyNodeConflictHeader = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue') +const lazyNodeConflictFooter = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue') + export type ConfirmationDialogType = | 'default' | 'overwrite' @@ -58,9 +81,19 @@ export interface ExecutionErrorDialogInput { export const useDialogService = () => { const dialogStore = useDialogStore() - function showLoadWorkflowWarning( + async function showLoadWorkflowWarning( props: ComponentAttrs ) { + const [ + { default: MissingNodesContent }, + { default: MissingNodesHeader }, + { default: MissingNodesFooter } + ] = await Promise.all([ + lazyMissingNodesContent(), + lazyMissingNodesHeader(), + lazyMissingNodesFooter() + ]) + dialogStore.showDialog({ key: 'global-missing-nodes', headerComponent: MissingNodesHeader, @@ -84,9 +117,10 @@ export const useDialogService = () => { }) } - function showMissingModelsWarning( + async function showMissingModelsWarning( props: ComponentAttrs ) { + const { default: MissingModelsWarning } = await lazyMissingModelsWarning() dialogStore.showDialog({ key: 'global-missing-models-warning', component: MissingModelsWarning, @@ -94,7 +128,7 @@ export const useDialogService = () => { }) } - function showSettingsDialog( + async function showSettingsDialog( panel?: | 'about' | 'keybinding' @@ -106,6 +140,14 @@ export const useDialogService = () => { | 'workspace' | 'secrets' ) { + const [ + { default: SettingDialogHeader }, + { default: SettingDialogContent } + ] = await Promise.all([ + lazySettingDialogHeader(), + lazySettingDialogContent() + ]) + const props = panel ? { props: { defaultPanel: panel } } : undefined dialogStore.showDialog({ @@ -116,7 +158,15 @@ export const useDialogService = () => { }) } - function showAboutDialog() { + async function showAboutDialog() { + const [ + { default: SettingDialogHeader }, + { default: SettingDialogContent } + ] = await Promise.all([ + lazySettingDialogHeader(), + lazySettingDialogContent() + ]) + dialogStore.showDialog({ key: 'global-settings', headerComponent: SettingDialogHeader, @@ -223,6 +273,9 @@ export const useDialogService = () => { async function showApiNodesSignInDialog( apiNodeNames: string[] ): Promise { + const [{ default: ApiNodesSignInContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazyApiNodesSignInContent(), lazyComfyOrgHeader()]) + return new Promise((resolve) => { dialogStore.showDialog({ key: 'api-nodes-signin', @@ -245,6 +298,9 @@ export const useDialogService = () => { } async function showSignInDialog(): Promise { + const [{ default: SignInContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazySignInContent(), lazyComfyOrgHeader()]) + return new Promise((resolve) => { dialogStore.showDialog({ key: 'global-signin', @@ -340,12 +396,15 @@ export const useDialogService = () => { }) } - function showTopUpCreditsDialog(options?: { + async function showTopUpCreditsDialog(options?: { isInsufficientCredits?: boolean }) { const { isActiveSubscription } = useSubscription() if (!isActiveSubscription.value) return + const { default: TopUpCreditsDialogContent } = + await lazyTopUpCreditsDialogContent() + return dialogStore.showDialog({ key: 'top-up-credits', component: TopUpCreditsDialogContent, @@ -364,7 +423,10 @@ export const useDialogService = () => { /** * Shows a dialog for updating the current user's password. */ - function showUpdatePasswordDialog() { + async function showUpdatePasswordDialog() { + const [{ default: UpdatePasswordContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazyUpdatePasswordContent(), lazyComfyOrgHeader()]) + return dialogStore.showDialog({ key: 'global-update-password', component: UpdatePasswordContent, @@ -426,12 +488,22 @@ export const useDialogService = () => { }) } - function showImportFailedNodeDialog( + async function showImportFailedNodeDialog( options: { conflictedPackages?: ConflictDetectionResult[] dialogComponentProps?: DialogComponentProps } = {} ) { + const [ + { default: ImportFailedNodeHeader }, + { default: ImportFailedNodeFooter }, + { default: ImportFailedNodeContent } + ] = await Promise.all([ + lazyImportFailedNodeHeader(), + lazyImportFailedNodeFooter(), + lazyImportFailedNodeContent() + ]) + const { dialogComponentProps, conflictedPackages } = options return dialogStore.showDialog({ @@ -463,7 +535,7 @@ export const useDialogService = () => { }) } - function showNodeConflictDialog( + async function showNodeConflictDialog( options: { showAfterWhatsNew?: boolean conflictedPackages?: ConflictDetectionResult[] @@ -472,6 +544,16 @@ export const useDialogService = () => { onButtonClick?: () => void } = {} ) { + const [ + { default: NodeConflictHeader }, + { default: NodeConflictFooter }, + { default: NodeConflictDialogContent } + ] = await Promise.all([ + lazyNodeConflictHeader(), + lazyNodeConflictFooter(), + lazyNodeConflictDialogContent() + ]) + const { dialogComponentProps, buttonText, diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index 687fd421a..632331752 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -1,11 +1,76 @@ +/** + * Load3D Service - provides access to Load3D instances + * + * This service uses lazy imports to avoid pulling THREE.js into the main bundle. + * The nodeToLoad3dMap is accessed lazily - it will only be available after + * the load3d extension has been loaded. + */ import { toRaw } from 'vue' -import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils' -import { nodeToLoad3dMap } from '@/composables/useLoad3d' -import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import type Load3d from '@/extensions/core/load3d/Load3d' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { Object3D } from 'three' + +// Type for the useLoad3dViewer composable function +// Using explicit type to avoid import() type annotations (lint rule) +type UseLoad3dViewerFn = (node?: LGraphNode) => { + initializeViewer: (containerRef: HTMLElement, source: Load3d) => Promise + initializeStandaloneViewer: ( + containerRef: HTMLElement, + modelUrl: string + ) => Promise + cleanup: () => void + handleResize: () => void + handleMouseEnter: () => void + handleMouseLeave: () => void + applyChanges: () => Promise + restoreInitialState: () => void + refreshViewport: () => void + exportModel: (format: string) => Promise + handleBackgroundImageUpdate: (file: File | null) => Promise + handleModelDrop: (file: File) => Promise + handleSeek: (progress: number) => void + needApplyChanges: { value: boolean } + [key: string]: unknown +} + +// Type for SkeletonUtils module +type SkeletonUtilsModule = { clone: (source: Object3D) => Object3D } + +// Cache for lazy-loaded modules +let cachedNodeToLoad3dMap: Map | null = null +let cachedUseLoad3dViewer: UseLoad3dViewerFn | null = null +let cachedSkeletonUtils: SkeletonUtilsModule | null = null + +// Sync accessor - returns null if module not yet loaded +function getNodeToLoad3dMapSync(): Map | null { + return cachedNodeToLoad3dMap +} + +// Async loader for nodeToLoad3dMap - also caches for sync access +async function loadNodeToLoad3dMap(): Promise> { + if (!cachedNodeToLoad3dMap) { + const module = await import('@/composables/useLoad3d') + cachedNodeToLoad3dMap = module.nodeToLoad3dMap + } + return cachedNodeToLoad3dMap +} + +async function loadUseLoad3dViewer() { + if (!cachedUseLoad3dViewer) { + const module = await import('@/composables/useLoad3dViewer') + cachedUseLoad3dViewer = module.useLoad3dViewer + } + return cachedUseLoad3dViewer +} + +async function loadSkeletonUtils() { + if (!cachedSkeletonUtils) { + cachedSkeletonUtils = await import('three/examples/jsm/utils/SkeletonUtils') + } + return cachedSkeletonUtils +} // Type definitions for Load3D node interface SceneConfig { @@ -30,14 +95,30 @@ export class Load3dService { return Load3dService.instance } + /** + * Get Load3d instance for a node (synchronous). + * Returns null if the load3d module hasn't been loaded yet. + */ getLoad3d(node: LGraphNode): Load3d | null { const rawNode = toRaw(node) + const map = getNodeToLoad3dMapSync() + if (!map) return null + return map.get(rawNode) || null + } - return nodeToLoad3dMap.get(rawNode) || null + /** + * Get Load3d instance for a node (async, loads module if needed). + */ + async getLoad3dAsync(node: LGraphNode): Promise { + const rawNode = toRaw(node) + const map = await loadNodeToLoad3dMap() + return map.get(rawNode) || null } getNodeByLoad3d(load3d: Load3d): LGraphNode | null { - for (const [node, instance] of nodeToLoad3dMap) { + const map = getNodeToLoad3dMapSync() + if (!map) return null + for (const [node, instance] of map) { if (instance === load3d) { return node } @@ -47,23 +128,44 @@ export class Load3dService { removeLoad3d(node: LGraphNode) { const rawNode = toRaw(node) + const map = getNodeToLoad3dMapSync() + if (!map) return - const instance = nodeToLoad3dMap.get(rawNode) + const instance = map.get(rawNode) if (instance) { instance.remove() - - nodeToLoad3dMap.delete(rawNode) + map.delete(rawNode) } } clear() { - for (const [node] of nodeToLoad3dMap) { + const map = getNodeToLoad3dMapSync() + if (!map) return + for (const [node] of map) { this.removeLoad3d(node) } } - getOrCreateViewer(node: LGraphNode) { + /** + * Get or create viewer (async, loads module if needed). + * Use this for initial viewer creation. + */ + async getOrCreateViewer(node: LGraphNode) { + if (!viewerInstances.has(node.id)) { + const useLoad3dViewer = await loadUseLoad3dViewer() + viewerInstances.set(node.id, useLoad3dViewer(node)) + } + + return viewerInstances.get(node.id) + } + + /** + * Get or create viewer (sync version). + * Only works after useLoad3dViewer has been loaded. + * Returns null if module not yet loaded - use async version instead. + */ + getOrCreateViewerSync(node: LGraphNode, useLoad3dViewer: UseLoad3dViewerFn) { if (!viewerInstances.has(node.id)) { viewerInstances.set(node.id, useLoad3dViewer(node)) } @@ -98,6 +200,7 @@ export class Load3dService { } } else { // Use SkeletonUtils.clone for proper skeletal animation support + const SkeletonUtils = await loadSkeletonUtils() const modelClone = SkeletonUtils.clone(sourceModel) target.getModelManager().currentModel = modelClone @@ -184,7 +287,7 @@ export class Load3dService { } async handleViewerClose(node: LGraphNode) { - const viewer = useLoad3dService().getOrCreateViewer(node) + const viewer = await useLoad3dService().getOrCreateViewer(node) if (viewer.needApplyChanges.value) { await viewer.applyChanges() diff --git a/src/workbench/extensions/manager/composables/useImportFailedDetection.ts b/src/workbench/extensions/manager/composables/useImportFailedDetection.ts index 1edc65006..d125255c8 100644 --- a/src/workbench/extensions/manager/composables/useImportFailedDetection.ts +++ b/src/workbench/extensions/manager/composables/useImportFailedDetection.ts @@ -33,7 +33,7 @@ function createImportFailedDialog() { onClose?: () => void ) => { if (conflictedPackages && conflictedPackages.length > 0) { - showImportFailedNodeDialog({ + void showImportFailedNodeDialog({ conflictedPackages, dialogComponentProps: { onClose diff --git a/src/workbench/extensions/manager/composables/useManagerState.ts b/src/workbench/extensions/manager/composables/useManagerState.ts index 49c9debfd..3a161b76f 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.ts @@ -153,7 +153,7 @@ export function useManagerState() { switch (state) { case ManagerUIState.DISABLED: - dialogService.showSettingsDialog('extension') + void dialogService.showSettingsDialog('extension') break case ManagerUIState.LEGACY_UI: { @@ -173,7 +173,7 @@ export function useManagerState() { } // Fallback to extensions panel if not showing toast if (options?.showToastOnLegacyError === false) { - dialogService.showSettingsDialog('extension') + void dialogService.showSettingsDialog('extension') } } break diff --git a/vite.config.mts b/vite.config.mts index 0732698cb..bf5ed3b5c 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -419,6 +419,31 @@ export default defineConfig({ minify: SHOULD_MINIFY, target: 'es2022', sourcemap: GENERATE_SOURCEMAP, + // Exclude heavy optional vendor chunks from initial module preload + // These chunks are only needed when their features are used (3D, terminal, etc.) + modulePreload: { + resolveDependencies: (_filename, deps, { hostType }) => { + // Only filter for HTML entry points, not for dynamic imports + if (hostType !== 'html') return deps + + // Exclude heavy vendor chunks that should be lazy-loaded + // - vendor-three: 3D preview (Load3D nodes) + // - vendor-xterm: Terminal emulator (logs panel) + // - vendor-tiptap: Rich text editor (markdown widgets) + // - vendor-chart: Chart.js (stats/monitoring) + // - vendor-yjs: CRDT library (layout store, loaded on first graph) + const lazyVendors = [ + 'vendor-three', + 'vendor-xterm', + 'vendor-tiptap', + 'vendor-chart', + 'vendor-yjs' + ] + return deps.filter( + (dep) => !lazyVendors.some((vendor) => dep.includes(vendor)) + ) + } + }, rolldownOptions: { treeshake: { manualPureFunctions: [ @@ -442,52 +467,100 @@ export default defineConfig({ 'console.trace' ] }, - experimental: { - strictExecutionOrder: true - }, output: { keepNames: true, codeSplitting: { groups: [ + // Framework core - highest priority, very stable + { + name: 'vendor-vue-core', + test: /[\\/]node_modules[\\/](vue|@vue|pinia|vue-router)[\\/]/, + priority: 20 + }, + + { + name: 'vendor-firebase', + test: /[\\/]node_modules[\\/](@?firebase|@firebase)[\\/]/, + priority: 15 + }, + { + name: 'vendor-sentry', + test: /[\\/]node_modules[\\/]@sentry[\\/]/, + priority: 15 + }, + + // UI component libraries { name: 'vendor-primevue', test: /[\\/]node_modules[\\/](@?primevue|@primeuix)[\\/]/, - priority: 10 - }, - { - name: 'vendor-tiptap', - test: /[\\/]node_modules[\\/]@tiptap[\\/]/, - priority: 10 - }, - { - name: 'vendor-chart', - test: /[\\/]node_modules[\\/]chart\.js[\\/]/, - priority: 10 - }, - { - name: 'vendor-three', - test: /[\\/]node_modules[\\/](three|@sparkjsdev)[\\/]/, - priority: 10 - }, - { - name: 'vendor-xterm', - test: /[\\/]node_modules[\\/]@xterm[\\/]/, - priority: 10 - }, - { - name: 'vendor-vue', - test: /[\\/]node_modules[\\/](vue|pinia)[\\/]/, - priority: 10 + priority: 15 }, { name: 'vendor-reka-ui', test: /[\\/]node_modules[\\/]reka-ui[\\/]/, - priority: 10 + priority: 15 }, + + // Heavy optional features + { + name: 'vendor-three', + test: /[\\/]node_modules[\\/](three|@sparkjsdev)[\\/]/, + priority: 15 + }, + { + name: 'vendor-tiptap', + test: /[\\/]node_modules[\\/]@tiptap[\\/]/, + priority: 15 + }, + { + name: 'vendor-chart', + test: /[\\/]node_modules[\\/]chart\.js[\\/]/, + priority: 15 + }, + { + name: 'vendor-xterm', + test: /[\\/]node_modules[\\/]@xterm[\\/]/, + priority: 15 + }, + { + name: 'vendor-yjs', + test: /[\\/]node_modules[\\/](yjs|lib0)[\\/]/, + priority: 15 + }, + + // Utilities and validation + { + name: 'vendor-vueuse', + test: /[\\/]node_modules[\\/]@vueuse[\\/]/, + priority: 12 + }, + { + name: 'vendor-i18n', + test: /[\\/]node_modules[\\/](vue-i18n|@intlify)[\\/]/, + priority: 12 + }, + { + name: 'vendor-zod', + test: /[\\/]node_modules[\\/](zod|zod-validation-error)[\\/]/, + priority: 12 + }, + { + name: 'vendor-axios', + test: /[\\/]node_modules[\\/]axios[\\/]/, + priority: 12 + }, + { + name: 'vendor-markdown', + test: /[\\/]node_modules[\\/](marked|dompurify)[\\/]/, + priority: 12 + }, + + // Catch-all for remaining node_modules { name: 'vendor-other', test: /[\\/]node_modules[\\/]/, - priority: 0 + priority: 0, + minSize: 10000 } ] } From 380db3ee10a057e995177d4ac68cf8b6386709ac Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 2 Feb 2026 19:22:07 -0800 Subject: [PATCH 02/10] chore: add deprecation warning for legacy node templates extension (#8463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the legacy `nodeTemplates` extension to the `DEPRECATED_FILES` list in the build plugin, causing a console warning when external extensions import from this file. ## Changes Adds `'extensions/core/nodeTemplates'` to `DEPRECATED_FILES` in `build/plugins/comfyAPIPlugin.ts`. ## Effect When an external extension imports from `extensions/core/nodeTemplates.js`, they will see: ``` [ComfyUI Deprecated] Importing from "extensions/core/nodeTemplates.js" is deprecated and will be removed in v1.34. ``` ## Related - Follows pattern from PR #6090 (feat: deprecated API alert) - Related issue: #4056 (Migrate Node Templates to Workflows) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8463-chore-add-deprecation-warning-for-legacy-node-templates-extension-2f86d73d3650811e9f53fa3e5a572332) by [Unito](https://www.unito.io) Co-authored-by: Amp --- build/plugins/comfyAPIPlugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build/plugins/comfyAPIPlugin.ts b/build/plugins/comfyAPIPlugin.ts index 394914dbe..16135cbd7 100644 --- a/build/plugins/comfyAPIPlugin.ts +++ b/build/plugins/comfyAPIPlugin.ts @@ -9,7 +9,11 @@ interface ShimResult { const SKIP_WARNING_FILES = new Set(['scripts/app', 'scripts/api']) /** Files that will be removed in v1.34 */ -const DEPRECATED_FILES = ['scripts/ui', 'extensions/core/groupNode'] as const +const DEPRECATED_FILES = [ + 'scripts/ui', + 'extensions/core/groupNode', + 'extensions/core/nodeTemplates' +] as const function getWarningMessage( fileKey: string, From cc10684c196dbabb43148f83ccf33795fc004380 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 2 Feb 2026 19:54:50 -0800 Subject: [PATCH 03/10] feat: enable linear mode toggle for nightly builds (#8569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables the linear mode toggle for all nightly build users by short-circuiting the `linearToggleEnabled` feature flag. ## Changes - Adds `isNightly` check in `linearToggleEnabled` getter - Returns `true` for nightly builds, bypassing remote config/server feature checks - Adds unit tests for the new behavior ## Reviewers - @AustinMroz (linear mode maintainer) - @christian-byrne (isNightly author) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8569-feat-enable-linear-mode-toggle-for-nightly-builds-2fc6d73d3650819681f8dcdc23b6eefe) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **New Features** * Enabled linear toggle feature for nightly distribution builds. * Feature flag system now respects nightly vs. standard build configurations. * **Tests** * Added test coverage for nightly build feature flag behavior and remote configuration handling. --- src/composables/useFeatureFlags.test.ts | 44 +++++++++++++++++++++++++ src/composables/useFeatureFlags.ts | 4 ++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/composables/useFeatureFlags.test.ts b/src/composables/useFeatureFlags.test.ts index c2b3634f7..c1caea6cf 100644 --- a/src/composables/useFeatureFlags.test.ts +++ b/src/composables/useFeatureFlags.test.ts @@ -5,6 +5,7 @@ import { ServerFeatureFlag, useFeatureFlags } from '@/composables/useFeatureFlags' +import * as distributionTypes from '@/platform/distribution/types' import { api } from '@/scripts/api' // Mock the API module @@ -14,6 +15,12 @@ vi.mock('@/scripts/api', () => ({ } })) +// Mock the distribution types module +vi.mock('@/platform/distribution/types', () => ({ + isCloud: false, + isNightly: false +})) + describe('useFeatureFlags', () => { beforeEach(() => { vi.clearAllMocks() @@ -131,4 +138,41 @@ describe('useFeatureFlags', () => { expect(maxUploadSize.value).toBe(104857600) }) }) + + describe('linearToggleEnabled', () => { + it('should return true when isNightly is true', () => { + vi.mocked(distributionTypes).isNightly = true + + const { flags } = useFeatureFlags() + expect(flags.linearToggleEnabled).toBe(true) + expect(api.getServerFeature).not.toHaveBeenCalled() + }) + + it('should check remote config and server feature when isNightly is false', () => { + vi.mocked(distributionTypes).isNightly = false + vi.mocked(api.getServerFeature).mockImplementation( + (path, defaultValue) => { + if (path === ServerFeatureFlag.LINEAR_TOGGLE_ENABLED) return true + return defaultValue + } + ) + + const { flags } = useFeatureFlags() + expect(flags.linearToggleEnabled).toBe(true) + expect(api.getServerFeature).toHaveBeenCalledWith( + ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, + false + ) + }) + + it('should return false when isNightly is false and flag is disabled', () => { + vi.mocked(distributionTypes).isNightly = false + vi.mocked(api.getServerFeature).mockImplementation( + (_path, defaultValue) => defaultValue + ) + + const { flags } = useFeatureFlags() + expect(flags.linearToggleEnabled).toBe(false) + }) + }) }) diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index ab38451ab..e3ad35db0 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -1,6 +1,6 @@ import { computed, reactive, readonly } from 'vue' -import { isCloud } from '@/platform/distribution/types' +import { isCloud, isNightly } from '@/platform/distribution/types' import { isAuthenticatedConfigLoaded, remoteConfig @@ -65,6 +65,8 @@ export function useFeatureFlags() { ) }, get linearToggleEnabled() { + if (isNightly) return true + return ( remoteConfig.value.linear_toggle_enabled ?? api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false) From 5847071bc104a8484f739a0cb22e3df3a9267350 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Tue, 3 Feb 2026 14:15:05 +0900 Subject: [PATCH 04/10] [feat] Add node replacement store and types (#8364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## 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. --- .../nodeReplacement/nodeReplacementService.ts | 13 + .../nodeReplacementStore.test.ts | 240 ++++++++++++++++++ .../nodeReplacement/nodeReplacementStore.ts | 46 ++++ src/platform/nodeReplacement/types.ts | 29 +++ .../settings/constants/coreSettings.ts | 11 + src/schemas/apiSchema.ts | 1 + src/scripts/app.ts | 2 + 7 files changed, 342 insertions(+) create mode 100644 src/platform/nodeReplacement/nodeReplacementService.ts create mode 100644 src/platform/nodeReplacement/nodeReplacementStore.test.ts create mode 100644 src/platform/nodeReplacement/nodeReplacementStore.ts create mode 100644 src/platform/nodeReplacement/types.ts diff --git a/src/platform/nodeReplacement/nodeReplacementService.ts b/src/platform/nodeReplacement/nodeReplacementService.ts new file mode 100644 index 000000000..c50b30140 --- /dev/null +++ b/src/platform/nodeReplacement/nodeReplacementService.ts @@ -0,0 +1,13 @@ +import type { NodeReplacementResponse } from './types' + +import { api } from '@/scripts/api' + +export async function fetchNodeReplacements(): Promise { + 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() +} diff --git a/src/platform/nodeReplacement/nodeReplacementStore.test.ts b/src/platform/nodeReplacement/nodeReplacementStore.test.ts new file mode 100644 index 000000000..552ee41da --- /dev/null +++ b/src/platform/nodeReplacement/nodeReplacementStore.test.ts @@ -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 + + 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() + }) + }) +}) diff --git a/src/platform/nodeReplacement/nodeReplacementStore.ts b/src/platform/nodeReplacement/nodeReplacementStore.ts new file mode 100644 index 000000000..e2e058ebd --- /dev/null +++ b/src/platform/nodeReplacement/nodeReplacementStore.ts @@ -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({}) + 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 + } +}) diff --git a/src/platform/nodeReplacement/types.ts b/src/platform/nodeReplacement/types.ts new file mode 100644 index 000000000..efbfbba70 --- /dev/null +++ b/src/platform/nodeReplacement/types.ts @@ -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 diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 75fcefbf5..73f71725f 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1190,5 +1190,16 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'boolean', defaultValue: false, versionAdded: '1.39.0' + }, + { + id: 'Comfy.NodeReplacement.Enabled', + category: ['Comfy', 'Workflow', 'NodeReplacement'], + name: 'Enable automatic node replacement', + tooltip: + 'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.', + type: 'boolean', + defaultValue: true, + experimental: true, + versionAdded: '1.40.0' } ] diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index b75942a55..daca43c00 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -315,6 +315,7 @@ const zSettings = z.object({ 'Comfy.Node.MiddleClickRerouteNode': z.boolean(), 'Comfy.Node.ShowDeprecated': z.boolean(), 'Comfy.Node.ShowExperimental': z.boolean(), + 'Comfy.NodeReplacement.Enabled': z.boolean(), 'Comfy.Pointer.ClickBufferTime': z.number(), 'Comfy.Pointer.ClickDrift': z.number(), 'Comfy.Pointer.DoubleClickTime': z.number(), diff --git a/src/scripts/app.ts b/src/scripts/app.ts index a2f75880a..22db2fa05 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -63,6 +63,7 @@ import { KeyComboImpl } from '@/platform/keybindings/keyCombo' import { useKeybindingStore } from '@/platform/keybindings/keybindingStore' import { useModelStore } from '@/stores/modelStore' import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore' +import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore' import { useSubgraphStore } from '@/stores/subgraphStore' import { useWidgetStore } from '@/stores/widgetStore' import { useWorkspaceStore } from '@/stores/workspaceStore' @@ -762,6 +763,7 @@ export class ComfyApp { await useWorkspaceStore().workflow.syncWorkflows() //Doesn't need to block. Blueprints will load async void useSubgraphStore().fetchSubgraphs() + void useNodeReplacementStore().load() await useExtensionService().loadExtensions() this.addProcessKeyHandler() From ae7728cd91d77ce7f5ab8d04dc24a60bdcb7cb28 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 2 Feb 2026 21:44:30 -0800 Subject: [PATCH 05/10] feat: add welcome message for Linear Mode (#8562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a welcome/onboarding message for App Mode (Linear Mode) that displays when no workflow output is selected, targeting first-time users. | Before | After | | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Screenshot from 2026-02-02
20-47-30 | Screenshot from 2026-02-02
21-32-20 | ## Changes - **LinearWelcome.vue**: New component with 4 sections explaining App Mode interface, sharing workflows, and widget control - **LinearPreview.vue**: Replace dimmed logo fallback with LinearWelcome component - **i18n**: Add `linearMode.welcome.*` translation keys ## Review Focus - Copy targets users who have never used ComfyUI—assumes no knowledge of nodes/graphs - Component uses semantic Tailwind classes from design system Fixes COM-14274 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8562-feat-add-welcome-message-for-Linear-Mode-2fc6d73d365081b2b736f211152e1cce) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **New Features** * Added a comprehensive welcome screen in App Mode that introduces users to the simplified interface hiding node graphs, explains the layout structure with outputs and controls, provides instructions for sharing workflows as simple tools, and guides users on customizing which settings are visible through widget promotion. --- src/locales/en/main.json | 9 +++++- .../extensions/linearMode/LinearPreview.vue | 7 ++--- .../extensions/linearMode/LinearWelcome.vue | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 src/renderer/extensions/linearMode/LinearWelcome.vue diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 1cacbbb29..5add6a63f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2760,7 +2760,14 @@ "runCount": "Run count:", "rerun": "Rerun", "reuseParameters": "Reuse Parameters", - "downloadAll": "Download All" + "downloadAll": "Download All", + "welcome": { + "title": "Welcome to App Mode", + "intro": "A simplified view that hides the node graph so you can focus on creating.", + "layout": "On the left, you'll see your generated images, videos, and outputs. On the right, just the controls you need. Everything complex stays out of sight.", + "sharing": "Sharing is easy: create your workflow, open App Mode, right-click the tab, and export. When others open your file, it launches straight into this clean view. You can share powerful workflows as simple tools without anyone needing to understand node graphs.", + "widget": "If you want to control which settings appear, convert your top-level nodes into a subgraph, then use widget promotion in the toolbox above it to choose what's exposed." + } }, "missingNodes": { "cloud": { diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index dc384bef0..95bfdf16f 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -11,6 +11,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil' import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue' +import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue' // Lazy-loaded to avoid pulling THREE.js into the main bundle const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.vue') import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue' @@ -177,9 +178,5 @@ async function rerun(e: Event) { v-else-if="getMediaType(selectedOutput) === '3d'" :model-url="selectedOutput!.url" /> - + diff --git a/src/renderer/extensions/linearMode/LinearWelcome.vue b/src/renderer/extensions/linearMode/LinearWelcome.vue new file mode 100644 index 000000000..6d602d555 --- /dev/null +++ b/src/renderer/extensions/linearMode/LinearWelcome.vue @@ -0,0 +1,29 @@ + + + From a3fba58c797cdd6563d4485eac11fcc257ed5850 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 2 Feb 2026 21:46:12 -0800 Subject: [PATCH 06/10] refactor: move cancellable states to module-level constant (#8570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Moves static data outside the `useJobActions` composable to module-level. ## Changes - **What**: Extracted `cancellableStates` array to a module-level `CANCELLABLE_STATES` constant. The `cancelAction` object remains inside the composable because it depends on `t()` for i18n reactivity. ## Review Focus This is a pure refactor with no behavior change - the constant is now allocated once per module instead of per-composable-call. Fixes #7946 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8570-refactor-move-cancellable-states-to-module-level-constant-2fc6d73d3650814fa1fefa172f7ace2f) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **Chores** * Internal code refactoring to improve maintainability of job cancellation logic. --- src/composables/queue/useJobActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/composables/queue/useJobActions.ts b/src/composables/queue/useJobActions.ts index d350503d9..4e664c4c1 100644 --- a/src/composables/queue/useJobActions.ts +++ b/src/composables/queue/useJobActions.ts @@ -13,6 +13,8 @@ export type JobAction = { variant: 'destructive' | 'secondary' | 'textonly' } +const CANCELLABLE_STATES: JobState[] = ['pending', 'initialization', 'running'] + export function useJobActions( job: MaybeRefOrGetter ) { @@ -26,8 +28,6 @@ export function useJobActions( variant: 'destructive' } - const cancellableStates: JobState[] = ['pending', 'initialization', 'running'] - const jobRef = computed(() => toValue(job) ?? null) const canCancelJob = computed(() => { @@ -38,7 +38,7 @@ export function useJobActions( return ( currentJob.showClear !== false && - cancellableStates.includes(currentJob.state) + CANCELLABLE_STATES.includes(currentJob.state) ) }) From f9af2cc4bd35d88edfad865ca7ee48f6ff1f77c3 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Tue, 3 Feb 2026 04:24:33 -0500 Subject: [PATCH 07/10] feat: add aspect ratio lock for ImageCrop widget (#8533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add ratio preset dropdown (1:1, 3:4, 4:3, 16:9, 9:16, custom) and lock toggle to the ImageCrop widget. When locked, only corner handles are shown and resizing maintains the selected aspect ratio using diagonal projection for smooth, jump-free interaction. Locking with custom selected captures the current crop's ratio. ## Screenshots (if applicable) https://github.com/user-attachments/assets/a7b5c0a0-c18c-4785-940f-59793702e892 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8533-feat-add-aspect-ratio-lock-for-ImageCrop-widget-2fa6d73d365081a98546e43ed0d7d4fe) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **New Features** * Image crop widget adds a ratio selector with preset and custom options, plus a lock/unlock toggle to preserve aspect while cropping. * Constrained corner-resize behavior enforces locked aspect ratios during resizing. * **Documentation** * Added UI text entries for ratio controls (labels for ratio, lock/unlock, custom). --- src/components/imagecrop/WidgetImageCrop.vue | 48 ++++++- src/composables/useImageCrop.ts | 143 ++++++++++++++++++- src/locales/en/main.json | 6 +- 3 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue index 4a1c39ef6..d093ddad2 100644 --- a/src/components/imagecrop/WidgetImageCrop.vue +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -57,6 +57,41 @@ /> +
+ + + +
+ @@ -65,7 +100,13 @@ import { useTemplateRef } from 'vue' import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue' -import { useImageCrop } from '@/composables/useImageCrop' +import Button from '@/components/ui/button/Button.vue' +import Select from '@/components/ui/select/Select.vue' +import SelectContent from '@/components/ui/select/SelectContent.vue' +import SelectItem from '@/components/ui/select/SelectItem.vue' +import SelectTrigger from '@/components/ui/select/SelectTrigger.vue' +import SelectValue from '@/components/ui/select/SelectValue.vue' +import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { Bounds } from '@/renderer/core/layout/types' @@ -80,10 +121,15 @@ const modelValue = defineModel({ const imageEl = useTemplateRef('imageEl') const containerEl = useTemplateRef('containerEl') +const ratioKeys = Object.keys(ASPECT_RATIOS) + const { imageUrl, isLoading, + selectedRatio, + isLockEnabled, + cropBoxStyle, cropImageStyle, resizeHandles, diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts index da637ba0a..8a09d3564 100644 --- a/src/composables/useImageCrop.ts +++ b/src/composables/useImageCrop.ts @@ -22,6 +22,15 @@ const CORNER_SIZE = 10 const MIN_CROP_SIZE = 16 const CROP_BOX_BORDER = 2 +export const ASPECT_RATIOS = { + '1:1': 1, + '3:4': 3 / 4, + '4:3': 4 / 3, + '16:9': 16 / 9, + '9:16': 9 / 16, + custom: null +} as const + interface UseImageCropOptions { imageEl: Ref containerEl: Ref @@ -88,6 +97,55 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { const resizeStartCropWidth = ref(0) const resizeStartCropHeight = ref(0) + const lockedRatio = ref(null) + + const selectedRatio = computed({ + get: () => { + if (lockedRatio.value == null) return 'custom' + const entry = Object.entries(ASPECT_RATIOS).find( + ([, v]) => v === lockedRatio.value + ) + return entry ? entry[0] : 'custom' + }, + set: (key: string) => { + if (key === 'custom') { + lockedRatio.value = null + return + } + lockedRatio.value = + ASPECT_RATIOS[key as keyof typeof ASPECT_RATIOS] ?? null + applyLockedRatio() + } + }) + + const isLockEnabled = computed({ + get: () => lockedRatio.value != null, + set: (locked: boolean) => { + if (locked && lockedRatio.value == null) { + lockedRatio.value = cropWidth.value / cropHeight.value + } + if (!locked) { + lockedRatio.value = null + } + } + }) + + function applyLockedRatio() { + if (lockedRatio.value == null) return + + const ratio = lockedRatio.value + const w = cropWidth.value + let newHeight = Math.round(w / ratio) + + if (cropY.value + newHeight > naturalHeight.value) { + newHeight = naturalHeight.value - cropY.value + const newWidth = Math.round(newHeight * ratio) + cropWidth.value = Math.max(MIN_CROP_SIZE, newWidth) + } + + cropHeight.value = Math.max(MIN_CROP_SIZE, newHeight) + } + useResizeObserver(containerEl, () => { if (imageEl.value && imageUrl.value) { updateDisplayedDimensions() @@ -200,7 +258,9 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { } } - const resizeHandles = computed(() => { + const CORNER_DIRECTIONS = new Set(['nw', 'ne', 'sw', 'se']) + + const allResizeHandles = computed(() => { const x = imageOffsetX.value + cropX.value * scaleFactor.value const y = imageOffsetY.value + cropY.value * scaleFactor.value const w = cropWidth.value * scaleFactor.value @@ -286,6 +346,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { ] }) + const resizeHandles = computed(() => { + if (!isLockEnabled.value) return allResizeHandles.value + return allResizeHandles.value.filter((h) => + CORNER_DIRECTIONS.has(h.direction) + ) + }) + const handleImageLoad = () => { isLoading.value = false updateDisplayedDimensions() @@ -366,6 +433,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { const deltaX = (e.clientX - resizeStartX.value) / effectiveScale const deltaY = (e.clientY - resizeStartY.value) / effectiveScale + const ratioValue = isLockEnabled.value ? lockedRatio.value : null + + if (ratioValue != null && CORNER_DIRECTIONS.has(dir)) { + handleConstrainedResize(dir, deltaX, deltaY, ratioValue) + return + } + const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw' const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se' const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne' @@ -414,6 +488,70 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { } } + function handleConstrainedResize( + dir: ResizeDirection, + deltaX: number, + deltaY: number, + ratio: number + ) { + const affectsLeft = dir === 'nw' || dir === 'sw' + const affectsTop = dir === 'nw' || dir === 'ne' + + const sx = affectsLeft ? -1 : 1 + const sy = affectsTop ? -1 : 1 + + const invRatio = 1 / ratio + const dot = deltaX * sx + deltaY * sy * invRatio + const lenSq = 1 + invRatio * invRatio + const widthDelta = dot / lenSq + + let newWidth = Math.round(resizeStartCropWidth.value + widthDelta) + let newHeight = Math.round(newWidth / ratio) + + if (newWidth < MIN_CROP_SIZE) { + newWidth = MIN_CROP_SIZE + newHeight = Math.round(newWidth / ratio) + } + if (newHeight < MIN_CROP_SIZE) { + newHeight = MIN_CROP_SIZE + newWidth = Math.round(newHeight * ratio) + } + + let newX = resizeStartCropX.value + let newY = resizeStartCropY.value + + if (affectsLeft) { + newX = resizeStartCropX.value + resizeStartCropWidth.value - newWidth + } + if (affectsTop) { + newY = resizeStartCropY.value + resizeStartCropHeight.value - newHeight + } + + if (newX < 0) { + newWidth += newX + newX = 0 + newHeight = Math.round(newWidth / ratio) + } + if (newY < 0) { + newHeight += newY + newY = 0 + newWidth = Math.round(newHeight * ratio) + } + if (newX + newWidth > naturalWidth.value) { + newWidth = naturalWidth.value - newX + newHeight = Math.round(newWidth / ratio) + } + if (newY + newHeight > naturalHeight.value) { + newHeight = naturalHeight.value - newY + newWidth = Math.round(newHeight * ratio) + } + + cropX.value = Math.round(newX) + cropY.value = Math.round(newY) + cropWidth.value = Math.max(MIN_CROP_SIZE, newWidth) + cropHeight.value = Math.max(MIN_CROP_SIZE, newHeight) + } + const handleResizeEnd = (e: PointerEvent) => { if (!isResizing.value) return @@ -453,6 +591,9 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { cropWidth, cropHeight, + selectedRatio, + isLockEnabled, + cropBoxStyle, cropImageStyle, resizeHandles, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5add6a63f..c35e65fc9 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1785,7 +1785,11 @@ "imageCrop": { "loading": "Loading...", "noInputImage": "No input image connected", - "cropPreviewAlt": "Crop preview" + "cropPreviewAlt": "Crop preview", + "ratio": "Ratio", + "lockRatio": "Lock aspect ratio", + "unlockRatio": "Unlock aspect ratio", + "custom": "Custom" }, "boundingBox": { "x": "X", From b2c8ea3e50d63ad9ae105101f9471fa9acbb74f2 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:21:14 +0100 Subject: [PATCH 08/10] refactor: use emit events pattern instead of mutating props in widget components (#8549) --- src/components/rightSidePanel/RightSidePanel.vue | 8 ++++++++ .../rightSidePanel/parameters/SectionWidgets.vue | 10 ++++++++++ .../rightSidePanel/parameters/TabSubgraphInputs.vue | 7 +++++-- .../rightSidePanel/parameters/WidgetItem.vue | 9 +++++---- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue index 1ba514f66..2548b899b 100644 --- a/src/components/rightSidePanel/RightSidePanel.vue +++ b/src/components/rightSidePanel/RightSidePanel.vue @@ -8,6 +8,7 @@ import Tab from '@/components/tab/Tab.vue' import TabList from '@/components/tab/TabList.vue' import Button from '@/components/ui/button/Button.vue' import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy' +import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget' import { st } from '@/i18n' import { SubgraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -190,6 +191,12 @@ function handleTitleEdit(newTitle: string) { function handleTitleCancel() { isEditing.value = false } + +function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) { + if (!selectedSingleNode.value) return + ;(selectedSingleNode.value as SubgraphNode).properties.proxyWidgets = value + canvasStore.canvas?.setDirty(true, true) +}