@@ -1166,6 +1166,262 @@ test('node context integration preserves existing menu hooks and refreshes after
11661166 ] ) ;
11671167} ) ;
11681168
1169+ test ( 'LoRA Manager callback-driven widgets refresh the open workflow analysis' , ( ) => {
1170+ const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1171+ LoraLoaderV2 : new Set ( [ 'text' , 'loras' ] ) ,
1172+ 'Lora Loader (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1173+ 'Lora Stacker (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1174+ 'Power Lora Loader (rgthree)' : new Set ( ) ,
1175+ } ;
1176+ const configureCallbackDrivenModelWidgets = eval (
1177+ `(${ extractMethod ( modelResolverSource , 'configureCallbackDrivenModelWidgets' ) } )`
1178+ ) ;
1179+ const getCallbackDrivenModelListSignature = eval (
1180+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelListSignature' ) } )`
1181+ ) ;
1182+ const getCallbackDrivenModelStrengthSignature = eval (
1183+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelStrengthSignature' ) } )`
1184+ ) ;
1185+ const calls = [ ] ;
1186+ const lorasWidget = {
1187+ name : 'loras' ,
1188+ value : [ ] ,
1189+ callback ( value ) {
1190+ calls . push ( [ 'original-loras' , value ] ) ;
1191+ return 'callback-result' ;
1192+ } ,
1193+ } ;
1194+ const textWidget = {
1195+ name : 'text' ,
1196+ callback ( value ) {
1197+ calls . push ( [ 'original-text' , value ] ) ;
1198+ if ( value === '<lora:second:1>' ) {
1199+ lorasWidget . value = [
1200+ ...lorasWidget . value ,
1201+ { name : 'second' , strength : 1 , active : true } ,
1202+ ] ;
1203+ }
1204+ } ,
1205+ } ;
1206+ const unrelatedWidget = {
1207+ name : 'seed' ,
1208+ callback ( ) {
1209+ calls . push ( [ 'unrelated' ] ) ;
1210+ } ,
1211+ } ;
1212+ const resolver = {
1213+ callbackDrivenModelWidgetStates : new WeakMap ( ) ,
1214+ getCallbackDrivenModelListSignature,
1215+ getCallbackDrivenModelStrengthSignature,
1216+ scheduleNodeContextMenuAnalysis : ( ) => calls . push ( [ 'context-analysis' ] ) ,
1217+ dialog : {
1218+ isWorkflowRefreshSuppressed : ( ) => false ,
1219+ scheduleActiveWorkflowRefresh : reason => calls . push ( [ 'dialog-refresh' , reason ] ) ,
1220+ } ,
1221+ } ;
1222+ const node = {
1223+ comfyClass : 'Lora Loader (LoraManager)' ,
1224+ widgets : [ textWidget , lorasWidget , unrelatedWidget ] ,
1225+ } ;
1226+
1227+ configureCallbackDrivenModelWidgets . call ( resolver , node ) ;
1228+ lorasWidget . value = [ { name : 'existing' , strength : 1 , active : true } ] ;
1229+ configureCallbackDrivenModelWidgets . call ( resolver , node ) ;
1230+
1231+ textWidget . callback ( '<lora:second' ) ;
1232+ lorasWidget . value = [ { name : 'existing' , strength : 0.5 , active : true } ] ;
1233+ assert . equal ( lorasWidget . callback ( lorasWidget . value ) , 'callback-result' ) ;
1234+ textWidget . callback ( '<lora:second:1>' ) ;
1235+ unrelatedWidget . callback ( ) ;
1236+ assert . deepEqual ( calls , [
1237+ [ 'original-text' , '<lora:second' ] ,
1238+ [ 'original-loras' , [ { name : 'existing' , strength : 0.5 , active : true } ] ] ,
1239+ [ 'original-text' , '<lora:second:1>' ] ,
1240+ [ 'context-analysis' ] ,
1241+ [ 'dialog-refresh' , 'node-widget-change' ] ,
1242+ [ 'unrelated' ] ,
1243+ ] ) ;
1244+ } ) ;
1245+
1246+ test ( 'callback-driven widget refresh respects internal workflow update suppression' , ( ) => {
1247+ const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1248+ LoraLoaderV2 : new Set ( [ 'text' , 'loras' ] ) ,
1249+ 'Lora Loader (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1250+ 'Lora Stacker (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1251+ 'Power Lora Loader (rgthree)' : new Set ( ) ,
1252+ } ;
1253+ const configureCallbackDrivenModelWidgets = eval (
1254+ `(${ extractMethod ( modelResolverSource , 'configureCallbackDrivenModelWidgets' ) } )`
1255+ ) ;
1256+ const getCallbackDrivenModelListSignature = eval (
1257+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelListSignature' ) } )`
1258+ ) ;
1259+ const getCallbackDrivenModelStrengthSignature = eval (
1260+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelStrengthSignature' ) } )`
1261+ ) ;
1262+ const calls = [ ] ;
1263+ const widget = {
1264+ name : 'loras' ,
1265+ value : [ ] ,
1266+ callback : ( ) => calls . push ( 'original' ) ,
1267+ } ;
1268+ const resolver = {
1269+ callbackDrivenModelWidgetStates : new WeakMap ( ) ,
1270+ getCallbackDrivenModelListSignature,
1271+ getCallbackDrivenModelStrengthSignature,
1272+ scheduleNodeContextMenuAnalysis : ( ) => calls . push ( 'context-analysis' ) ,
1273+ dialog : {
1274+ isWorkflowRefreshSuppressed : ( ) => true ,
1275+ scheduleActiveWorkflowRefresh : ( ) => calls . push ( 'dialog-refresh' ) ,
1276+ } ,
1277+ } ;
1278+
1279+ configureCallbackDrivenModelWidgets . call ( resolver , {
1280+ comfyClass : 'Lora Stacker (LoraManager)' ,
1281+ widgets : [ widget ] ,
1282+ } ) ;
1283+ widget . value = [ { name : 'new-lora' , strength : 1 , active : true } ] ;
1284+ widget . callback ( widget . value ) ;
1285+
1286+ assert . deepEqual ( calls , [ 'original' ] ) ;
1287+ } ) ;
1288+
1289+ test ( 'rgthree Power Lora Loader updates strength without reloading workflow analysis' , async ( ) => {
1290+ const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1291+ LoraLoaderV2 : new Set ( [ 'text' , 'loras' ] ) ,
1292+ 'Lora Loader (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1293+ 'Lora Stacker (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1294+ 'Power Lora Loader (rgthree)' : new Set ( ) ,
1295+ } ;
1296+ const configureCallbackDrivenModelWidgets = eval (
1297+ `(${ extractMethod ( modelResolverSource , 'configureCallbackDrivenModelWidgets' ) } )`
1298+ ) ;
1299+ const getCallbackDrivenModelListSignature = eval (
1300+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelListSignature' ) } )`
1301+ ) ;
1302+ const getCallbackDrivenModelStrengthSignature = eval (
1303+ `(${ extractMethod ( modelResolverSource , 'getCallbackDrivenModelStrengthSignature' ) } )`
1304+ ) ;
1305+ const calls = [ ] ;
1306+ const node = {
1307+ comfyClass : 'Power Lora Loader (rgthree)' ,
1308+ widgets : [ {
1309+ name : 'lora_1' ,
1310+ value : {
1311+ on : true ,
1312+ lora : 'existing.safetensors' ,
1313+ strength : 1 ,
1314+ } ,
1315+ } ] ,
1316+ setDirtyCanvas ( ) {
1317+ calls . push ( 'dirty' ) ;
1318+ } ,
1319+ } ;
1320+ const resolver = {
1321+ callbackDrivenModelWidgetStates : new WeakMap ( ) ,
1322+ getCallbackDrivenModelListSignature,
1323+ getCallbackDrivenModelStrengthSignature,
1324+ scheduleNodeContextMenuAnalysis : ( ) => calls . push ( 'context-analysis' ) ,
1325+ dialog : {
1326+ isWorkflowRefreshSuppressed : ( ) => false ,
1327+ updateLoadedModelStrengthsFromNode : ( ) => calls . push ( 'loaded-strength-update' ) ,
1328+ scheduleActiveWorkflowRefresh : reason => calls . push ( `dialog-refresh:${ reason } ` ) ,
1329+ } ,
1330+ } ;
1331+
1332+ configureCallbackDrivenModelWidgets . call ( resolver , node ) ;
1333+
1334+ node . widgets [ 0 ] . value . strength = 0.5 ;
1335+ node . setDirtyCanvas ( true , true ) ;
1336+ await Promise . resolve ( ) ;
1337+
1338+ node . widgets . push ( {
1339+ name : 'lora_2' ,
1340+ value : {
1341+ on : true ,
1342+ lora : 'new-model.safetensors' ,
1343+ strength : 1 ,
1344+ } ,
1345+ } ) ;
1346+ node . setDirtyCanvas ( true , true ) ;
1347+ await Promise . resolve ( ) ;
1348+
1349+ assert . deepEqual ( calls , [
1350+ 'dirty' ,
1351+ 'loaded-strength-update' ,
1352+ 'dirty' ,
1353+ 'context-analysis' ,
1354+ 'dialog-refresh:node-widget-change' ,
1355+ ] ) ;
1356+ } ) ;
1357+
1358+ test ( 'rgthree dynamic lora widgets bypass the generic full workflow refresh' , ( ) => {
1359+ const CALLBACK_DRIVEN_MODEL_WIDGETS = {
1360+ LoraLoaderV2 : new Set ( [ 'text' , 'loras' ] ) ,
1361+ 'Lora Loader (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1362+ 'Lora Stacker (LoraManager)' : new Set ( [ 'text' , 'loras' ] ) ,
1363+ 'Power Lora Loader (rgthree)' : new Set ( ) ,
1364+ } ;
1365+ const configureNodeContextMenu = eval (
1366+ `(${ extractMethod ( modelResolverSource , 'configureNodeContextMenu' ) } )`
1367+ ) ;
1368+ const isCallbackDrivenModelWidget = eval (
1369+ `(${ extractMethod ( modelResolverSource , 'isCallbackDrivenModelWidget' ) } )`
1370+ ) ;
1371+ const calls = [ ] ;
1372+ class NodeType { }
1373+ NodeType . prototype . comfyClass = 'Power Lora Loader (rgthree)' ;
1374+ NodeType . prototype . onWidgetChanged = function ( name ) {
1375+ calls . push ( `original:${ name } ` ) ;
1376+ } ;
1377+ const node = new NodeType ( ) ;
1378+ const resolver = {
1379+ callbackDrivenModelWidgetStates : new WeakMap ( [
1380+ [ node , { notify : ( ) => calls . push ( 'selective-update' ) } ] ,
1381+ ] ) ,
1382+ getResolvedModelsForNodeContextMenu : ( ) => [ ] ,
1383+ isCallbackDrivenModelWidget,
1384+ scheduleNodeContextMenuAnalysis : ( ) => calls . push ( 'context-analysis' ) ,
1385+ dialog : {
1386+ isWorkflowRefreshSuppressed : ( ) => false ,
1387+ isWorkflowStrengthWidgetName : ( ) => false ,
1388+ scheduleActiveWorkflowRefresh : ( ) => calls . push ( 'dialog-refresh' ) ,
1389+ } ,
1390+ } ;
1391+
1392+ configureNodeContextMenu . call ( resolver , NodeType ) ;
1393+ node . onWidgetChanged ( 'lora_1' ) ;
1394+
1395+ assert . deepEqual ( calls , [
1396+ 'original:lora_1' ,
1397+ 'selective-update' ,
1398+ ] ) ;
1399+ } ) ;
1400+
1401+ test ( 'LoRA Manager text onWidgetChanged waits for a confirmed model-list change' , ( ) => {
1402+ const configureNodeContextMenu = eval ( `(${ extractMethod ( modelResolverSource , 'configureNodeContextMenu' ) } )` ) ;
1403+ const calls = [ ] ;
1404+ class NodeType { }
1405+ NodeType . prototype . onWidgetChanged = function ( name ) {
1406+ calls . push ( `original:${ name } ` ) ;
1407+ } ;
1408+ const resolver = {
1409+ getResolvedModelsForNodeContextMenu : ( ) => [ ] ,
1410+ isCallbackDrivenModelWidget : ( _node , widgetName ) => widgetName === 'text' ,
1411+ scheduleNodeContextMenuAnalysis : ( ) => calls . push ( 'context-analysis' ) ,
1412+ dialog : {
1413+ isWorkflowRefreshSuppressed : ( ) => false ,
1414+ isWorkflowStrengthWidgetName : ( ) => false ,
1415+ scheduleActiveWorkflowRefresh : ( ) => calls . push ( 'dialog-refresh' ) ,
1416+ } ,
1417+ } ;
1418+
1419+ configureNodeContextMenu . call ( resolver , NodeType ) ;
1420+ new NodeType ( ) . onWidgetChanged ( 'text' ) ;
1421+
1422+ assert . deepEqual ( calls , [ 'original:text' ] ) ;
1423+ } ) ;
1424+
11691425test ( 'strength widget changes skip proactive node context analysis' , ( ) => {
11701426 const configureNodeContextMenu = eval ( `(${ extractMethod ( modelResolverSource , 'configureNodeContextMenu' ) } )` ) ;
11711427 const isWorkflowStrengthWidgetName = eval (
@@ -1425,6 +1681,83 @@ test('background Loaded Models refresh keeps the current view until new data is
14251681 assert . equal ( contentElement . scrollTop , 48 ) ;
14261682} ) ;
14271683
1684+ test ( 'Power Lora Loader strength updates only its Loaded Models chip and cache' , ( ) => {
1685+ const updateLoadedModelStrengthsFromNode = eval (
1686+ `(${ extractMethod ( tabsLoadedMethodsSource , 'updateLoadedModelStrengthsFromNode' ) } )`
1687+ ) ;
1688+ const normalizeLoadedModelIdentity = eval (
1689+ `(${ extractMethod ( tabsLoadedMethodsSource , 'normalizeLoadedModelIdentity' ) } )`
1690+ ) ;
1691+ const getLoadedModelDomKey = eval (
1692+ `(${ extractMethod ( tabsLoadedMethodsSource , 'getLoadedModelDomKey' ) } )`
1693+ ) ;
1694+ const strengthElement = { textContent : '1.00' } ;
1695+ const chip = {
1696+ dataset : { mlLoadedModelKey : 'model-key' } ,
1697+ querySelector : selector => (
1698+ selector === '.mr-model-chip-strength' ? strengthElement : null
1699+ ) ,
1700+ } ;
1701+ const contentElement = {
1702+ querySelectorAll : selector => (
1703+ selector === '[data-ml-loaded-model-key]' ? [ chip ] : [ ]
1704+ ) ,
1705+ } ;
1706+ const loadedModel = {
1707+ node_id : 91 ,
1708+ widget_index : 0 ,
1709+ nested_key : 'lora' ,
1710+ category : 'loras' ,
1711+ original_path : 'styles/existing.safetensors' ,
1712+ strength : 1 ,
1713+ } ;
1714+ const calls = [ ] ;
1715+ const dialog = {
1716+ activeTab : 'loaded' ,
1717+ contentElement,
1718+ cachedLoadedModelsData : {
1719+ loaded_models : [ loadedModel ] ,
1720+ total : 1 ,
1721+ } ,
1722+ cachedLoadedModelsSignature : 'old-signature' ,
1723+ activeWorkflowSignature : 'old-signature' ,
1724+ normalizeLoadedModelIdentity,
1725+ getLoadedModelDomKey,
1726+ getMissingModelKey : ( ) => 'model-key' ,
1727+ updateLoadedModelCopyValues : ( _container , models ) => {
1728+ calls . push ( [ 'copy-values' , models [ 0 ] . strength ] ) ;
1729+ } ,
1730+ getCurrentWorkflow : ( ) => ( { nodes : [ { id : 91 } ] } ) ,
1731+ getWorkflowSignature : ( ) => 'strength-signature' ,
1732+ saveLoadedModelsCacheForActiveWorkflow : ( ) => calls . push ( [ 'save-cache' ] ) ,
1733+ displayLoadedModels : ( ) => calls . push ( [ 'full-render' ] ) ,
1734+ } ;
1735+ const node = {
1736+ id : 91 ,
1737+ comfyClass : 'Power Lora Loader (rgthree)' ,
1738+ widgets : [ {
1739+ name : 'lora_1' ,
1740+ value : {
1741+ on : true ,
1742+ lora : 'styles/existing.safetensors' ,
1743+ strength : 0.5 ,
1744+ } ,
1745+ } ] ,
1746+ } ;
1747+
1748+ const updated = updateLoadedModelStrengthsFromNode . call ( dialog , node ) ;
1749+
1750+ assert . equal ( updated , true ) ;
1751+ assert . equal ( loadedModel . strength , 0.5 ) ;
1752+ assert . equal ( strengthElement . textContent , '0.50' ) ;
1753+ assert . equal ( dialog . cachedLoadedModelsSignature , 'strength-signature' ) ;
1754+ assert . equal ( dialog . activeWorkflowSignature , 'strength-signature' ) ;
1755+ assert . deepEqual ( calls , [
1756+ [ 'copy-values' , 0.5 ] ,
1757+ [ 'save-cache' ] ,
1758+ ] ) ;
1759+ } ) ;
1760+
14281761test ( 'changed workflow model keeps the selected row and batch checkbox for the same loader slot' , ( ) => {
14291762 const encodeMissingModelKeyPart = eval ( `(${ extractMethod ( lifecycleGraphMethodsSource , 'encodeMissingModelKeyPart' ) } )` ) ;
14301763 const getMissingModelIdentityPart = eval ( `(${ extractMethod ( lifecycleGraphMethodsSource , 'getMissingModelIdentityPart' ) } )` ) ;
@@ -2209,6 +2542,49 @@ test('Missing Models signature ignores strength but tracks model identity change
22092542 ) ;
22102543} ) ;
22112544
2545+ test ( 'Missing Models signature tracks LoRA Manager list membership changes' , ( ) => {
2546+ const getWorkflowSignature = eval ( `(${ extractMethod ( workflowStateMethodsSource , 'getWorkflowSignature' ) } )` ) ;
2547+ const getMissingWorkflowSignature = eval ( `(${ extractMethod ( workflowStateMethodsSource , 'getMissingWorkflowSignature' ) } )` ) ;
2548+ const getWorkflowSignatureData = eval ( `(${ extractMethod ( workflowStateMethodsSource , 'getWorkflowSignatureData' ) } )` ) ;
2549+ const dialog = {
2550+ capabilities : { node_rules : { } } ,
2551+ getWorkflowSignature,
2552+ getMissingWorkflowSignature,
2553+ getWorkflowSignatureData,
2554+ } ;
2555+ const makeWorkflow = ( loras ) => ( {
2556+ nodes : [ {
2557+ id : 83 ,
2558+ type : 'Lora Loader (LoraManager)' ,
2559+ widgets_values : [
2560+ { version : 1 , textWidgetName : 'text' } ,
2561+ '<lora:first:1>' ,
2562+ loras ,
2563+ ] ,
2564+ } ] ,
2565+ links : [ ] ,
2566+ } ) ;
2567+ const original = makeWorkflow ( [
2568+ { name : 'first' , strength : 1 , active : true } ,
2569+ ] ) ;
2570+ const strengthChanged = makeWorkflow ( [
2571+ { name : 'first' , strength : 0.5 , active : true } ,
2572+ ] ) ;
2573+ const loraAdded = makeWorkflow ( [
2574+ { name : 'first' , strength : 0.5 , active : true } ,
2575+ { name : 'second' , strength : 1 , active : true } ,
2576+ ] ) ;
2577+
2578+ assert . equal (
2579+ getMissingWorkflowSignature . call ( dialog , original ) ,
2580+ getMissingWorkflowSignature . call ( dialog , strengthChanged )
2581+ ) ;
2582+ assert . notEqual (
2583+ getMissingWorkflowSignature . call ( dialog , strengthChanged ) ,
2584+ getMissingWorkflowSignature . call ( dialog , loraAdded )
2585+ ) ;
2586+ } ) ;
2587+
22122588test ( 'workflow hash refresh ignores node movement and tracks model dependency changes' , ( ) => {
22132589 const getWorkflowSignature = eval ( `(${ extractMethod ( workflowStateMethodsSource , 'getWorkflowSignature' ) } )` ) ;
22142590 const getWorkflowSignatureData = eval ( `(${ extractMethod ( workflowStateMethodsSource , 'getWorkflowSignatureData' ) } )` ) ;
0 commit comments