Compare commits

...

3 Commits

Author SHA1 Message Date
dante01yoon
e88e7e6f6e fix: guard progress_text before canvas init 2026-04-12 18:14:41 +09:00
dante01yoon
9baeb211ef fix: reorder node widgets to follow store order on pure reorder 2026-04-07 16:18:41 +09:00
dante01yoon
2f1ac34395 fix: preserve user widget order through _syncPromotions
Replace linked-first ordering in _buildPromotionPersistenceState with
an order-preserving merge that keeps existing store entries in their
current position while pruning stale entries and appending new ones.

This fixes a regression from 74a48ab2 where removing the
shouldPersistLinkedOnly guard caused _syncPromotions to always
overwrite user-reordered widget order with linked-first ordering.

Fixes Notion: Bug: Subgraph widget reorder causes UI and panel mismatch
2026-04-07 16:00:02 +09:00
4 changed files with 169 additions and 8 deletions

View File

@@ -840,8 +840,52 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
{
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
},
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' }
])
})
test('syncPromotions preserves user-reordered store order', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widget_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('widget_a', '*')
linkedNode.addWidget('text', 'widget_a', 'val_a', () => {})
linkedInput.widget = { name: 'widget_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'indep_widget', 'val_b', () => {})
subgraph.add(independentNode)
// Simulate user reorder: independent first, then linked
setPromotions(subgraphNode, [
[String(independentNode.id), 'indep_widget'],
[String(linkedNode.id), 'widget_a']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Order must be preserved: independent first, linked second
expect(promotions).toStrictEqual([
{
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'indep_widget'
},
{ sourceNodeId: String(linkedNode.id), sourceWidgetName: 'widget_a' }
])
})

View File

@@ -274,7 +274,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
const views = this._promotedViewManager.reconcile(
const reconciledViews = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
@@ -287,6 +287,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
)
const views = this._reorderViewsByStoreEntries(reconciledViews, entries)
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
@@ -385,13 +387,89 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
fallbackStoredEntries
)
const desiredEntries = shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
mergedEntries: this._orderPreservingMerge(entries, desiredEntries)
}
}
private _orderPreservingMerge(
currentEntries: PromotedWidgetSource[],
desiredEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
const makeKey = (e: PromotedWidgetSource) =>
this._makePromotionEntryKey(
e.sourceNodeId,
e.sourceWidgetName,
e.disambiguatingSourceNodeId
)
const desiredByKey = new Map(desiredEntries.map((e) => [makeKey(e), e]))
const currentKeys = new Set(currentEntries.map(makeKey))
const preserved = currentEntries
.filter((e) => desiredByKey.has(makeKey(e)))
.map((e) => desiredByKey.get(makeKey(e))!)
const added = desiredEntries.filter((e) => !currentKeys.has(makeKey(e)))
return [...preserved, ...added]
}
/**
* Reorders reconciled views to follow the current store entry order,
* but only when the view set exactly matches the store entries (pure
* reorder). When entries were added or removed (e.g. connection change),
* the reconcile order is preserved.
*/
private _reorderViewsByStoreEntries(
views: PromotedWidgetView[],
storeEntries: PromotedWidgetSource[]
): PromotedWidgetView[] {
if (views.length <= 1 || storeEntries.length === 0) return views
const makeKey = (e: PromotedWidgetSource) =>
this._makePromotionEntryKey(
e.sourceNodeId,
e.sourceWidgetName,
e.disambiguatingSourceNodeId
)
const storeKeys = new Set(storeEntries.map(makeKey))
const viewKeys = new Set(views.map(makeKey))
if (storeKeys.size !== viewKeys.size) return views
for (const key of viewKeys) {
if (!storeKeys.has(key)) return views
}
const viewsByKey = new Map<string, PromotedWidgetView[]>()
for (const v of views) {
const key = makeKey(v)
const group = viewsByKey.get(key)
if (group) group.push(v)
else viewsByKey.set(key, [v])
}
const emittedKeys = new Set<string>()
const ordered: PromotedWidgetView[] = []
for (const entry of storeEntries) {
const key = makeKey(entry)
if (emittedKeys.has(key)) continue
const group = viewsByKey.get(key)
if (group) {
ordered.push(...group)
emittedKeys.add(key)
}
}
return ordered
}
private _collectLinkedAndFallbackEntries(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]

View File

@@ -1,6 +1,7 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -10,6 +11,7 @@ import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
const mockNodeExecutionIdToNodeLocatorId = vi.fn()
const mockNodeIdToNodeLocatorId = vi.fn()
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
const mockShowTextPreview = vi.fn()
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
@@ -38,7 +40,7 @@ declare global {
vi.mock('@/composables/node/useNodeProgressText', () => ({
useNodeProgressText: () => ({
showTextPreview: vi.fn()
showTextPreview: mockShowTextPreview
})
}))
@@ -431,6 +433,40 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - progress_text startup guard', () => {
let store: ReturnType<typeof useExecutionStore>
function fireProgressText(detail: {
nodeId: string
text: string
prompt_id?: string
}) {
const handler = apiEventHandlers.get('progress_text')
if (!handler) throw new Error('progress_text handler not bound')
handler(new CustomEvent('progress_text', { detail }))
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
useCanvasStore().canvas = null
})
it('should ignore progress_text before the canvas is initialized', () => {
expect(() =>
fireProgressText({
nodeId: '1',
text: 'warming up'
})
).not.toThrow()
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
let store: ReturnType<typeof useExecutionErrorStore>

View File

@@ -527,7 +527,10 @@ export const useExecutionStore = defineStore('execution', () => {
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
if (!currentId) return
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
const canvas = canvasStore.canvas
if (!canvas) return
const node = canvas.graph?.getNodeById(currentId)
if (!node) return
useNodeProgressText().showTextPreview(node, text)