Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
5a2acc7668 Merge branch 'main' into fix/glsl-promoted-preview-propagation 2026-03-15 23:07:30 -07:00
bymyself
549dd5d36c test: add mockReset and reactive-update test per review feedback 2026-03-12 03:44:42 -07:00
bymyself
21b25f485e fix: use dispatchCustomEvent in subgraph promotion E2E test 2026-03-12 03:35:42 -07:00
bymyself
eec4e237d6 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
2026-03-12 03:35:42 -07:00
bymyself
8d230066c8 fix: track nodePreviewImages in usePromotedPreviews for GLSL live preview propagation 2026-03-12 03:35:42 -07:00
4 changed files with 178 additions and 30 deletions

View File

@@ -606,6 +606,45 @@ 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.dispatchCustomEvent('executed', {
prompt_id: 'test-prompt-id',
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

@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'getNodeImageUrls'
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
@@ -35,6 +35,7 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
@@ -71,12 +72,24 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -119,7 +132,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
@@ -143,9 +156,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.webm'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
@@ -162,9 +173,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.mp3'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
@@ -194,13 +203,11 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -208,6 +215,58 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
@@ -235,6 +294,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 })
@@ -253,7 +344,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)

View File

@@ -39,16 +39,18 @@ export function usePromotedPreviews(
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
if (!interiorNode) continue
// Read from the reactive nodeOutputs ref to establish Vue
// dependency tracking. getNodeImageUrls reads from the
// non-reactive app.nodeOutputs, so without this access the
// computed would never re-evaluate when outputs change.
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
)
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
if (!_reactiveOutputs?.images?.length) continue
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue

View File

@@ -15,24 +15,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 {
@@ -64,6 +75,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) ?? EMPTY_PROMOTIONS
)
@@ -112,6 +125,7 @@ export const usePromotionStore = defineStore('promotion', () => {
} else {
promotions.set(subgraphNodeId, [...entries])
}
_touch()
}
function promote(
@@ -171,11 +185,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 {