fix: ensure promotionStore mutations trigger reactive re-evaluation

Add version counter to promotionStore that guarantees computed properties
re-evaluate when promotions change. The nested Map<UUID, Map<NodeId, ...>>
structure can miss reactive notifications when inner Maps are lazily
initialized and returned as raw references.

- Add _version ref incremented on setPromotions/movePromotion/clearGraph
- Read _version in getPromotionsRef to establish reactive dependency
- Re-read inner Maps through reactive proxy after lazy init
- Add unit test for late-promotion reactivity scenario
- Add E2E test for promoted preview rendering via synthetic execution
This commit is contained in:
bymyself
2026-03-07 13:18:50 -08:00
parent 8d230066c8
commit eec4e237d6
3 changed files with 98 additions and 8 deletions

View File

@@ -553,6 +553,48 @@ test.describe(
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
})
test('Promoted preview renders when outputs are injected for interior node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// Simulate backend sending execution results for interior SaveImage
// node (id 10) inside the SubgraphNode (id 5).
// The executed event handler converts execution ID "5:10" to a
// NodeLocatorId using the subgraph UUID, then sets both
// app.nodeOutputs and the reactive nodeOutputs ref.
await comfyPage.page.evaluate(() => {
const api = window.app!.api
api.dispatchEvent(
new CustomEvent('executed', {
detail: {
node: '5:10',
display_node: '5:10',
output: {
images: [
{
filename: 'example.png',
subfolder: '',
type: 'input'
}
]
}
}
})
)
})
// The promoted preview should render an image inside the SubgraphNode
const previewImage = subgraphVueNode.locator('.image-preview img')
await expect(previewImage).toBeVisible({ timeout: 5_000 })
})
})
test.describe('Nested Promoted Widget Disabled State', () => {

View File

@@ -266,6 +266,38 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('re-evaluates when promotion is added after initial mount', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
// Mount BEFORE any promotions exist
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
// Add promotion AFTER mount
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
// Seed outputs
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
// The computed should re-evaluate and return the preview
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
])
})
it('ignores non-$$ promoted widgets', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -13,24 +13,35 @@ export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
/**
* Monotonic counter incremented on every mutation. Dependents read this
* inside their computed/watch to guarantee re-evaluation even when the
* nested Map proxies don't propagate change notifications reliably.
*/
const _version = ref(0)
function _touch() {
_version.value++
}
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotionEntry[]> {
const promotions = graphPromotions.value.get(graphId)
let promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
graphPromotions.value.set(graphId, new Map<NodeId, PromotionEntry[]>())
// Re-read through the reactive proxy so callers get the tracked version
promotions = graphPromotions.value.get(graphId)!
return promotions
}
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
const refCounts = graphRefCounts.value.get(graphId)
let refCounts = graphRefCounts.value.get(graphId)
if (refCounts) return refCounts
const nextRefCounts = new Map<string, number>()
graphRefCounts.value.set(graphId, nextRefCounts)
return nextRefCounts
graphRefCounts.value.set(graphId, new Map<string, number>())
refCounts = graphRefCounts.value.get(graphId)!
return refCounts
}
function _makeKey(interiorNodeId: string, widgetName: string): string {
@@ -62,6 +73,8 @@ export const usePromotionStore = defineStore('promotion', () => {
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
// Read version to establish reactive dependency
void _version.value
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
}
@@ -107,6 +120,7 @@ export const usePromotionStore = defineStore('promotion', () => {
} else {
promotions.set(subgraphNodeId, [...entries])
}
_touch()
}
function promote(
@@ -165,11 +179,13 @@ export const usePromotionStore = defineStore('promotion', () => {
// Reordering does not change membership, so ref-counts remain valid.
promotions.set(subgraphNodeId, entries)
_touch()
}
function clearGraph(graphId: UUID): void {
graphPromotions.value.delete(graphId)
graphRefCounts.value.delete(graphId)
_touch()
}
return {