Compare commits

..

7 Commits

Author SHA1 Message Date
Glary-Bot
93959cb865 refactor: run base onNodeRemoved in finally so cleanup errors cannot starve downstream hooks 2026-05-13 20:00:28 +00:00
Glary-Bot
9e954a910e fix: skip tracker cache prune during workflow tab-switch teardown
The previous version pruned the per-workflow ChangeTracker output
cache on every onNodeRemoved fire. That broke workflowPersistence
specs because switching workflow tabs runs loadGraphData(clean=true)
on the outgoing workflow, which fires onNodeRemoved for each node
while activeWorkflow still points at the workflow being deactivated.
The hook was deleting the snapshot used to restore previews when the
user switched back.

Gate tracker pruning on a tab-switch signal:
ChangeTracker.isLoadingGraph is true AND the active tracker is not
_restoringState. The undo/redo path keeps pruning (the tracker is
restoring), the direct-delete path keeps pruning (no graph load in
flight), and the tab-switch teardown is now a no-op for the tracker
cache.
2026-05-13 19:51:39 +00:00
Glary-Bot
cb75f4e92d fix: prune ChangeTracker output cache so undo cannot resurrect stale entries
The ChangeTracker keeps its own per-workflow nodeOutputs cache that is
populated by the 'executed' event and re-applied via restoreOutputs()
during workflow load. Without pruning it on node removal, undo would
clear app.nodeOutputs through the new onNodeRemoved hook only to have
the same entry written back from the tracker's stale cache.

Hook now also derives the execution id for the removed node and
deletes it from the active workflow's tracker cache, recursing through
subgraph contents.
2026-05-12 04:45:50 +00:00
Glary-Bot
7a54e27397 fix: clear interior outputs when removing a subgraph container
Address review feedback: onNodeRemoved fires per interior node on the
subgraph object (not the parent graph), so removing a SubgraphNode
from the root left interior nodeOutputStore entries behind. Recurse
into subgraph.nodes (including nested subgraphs) and prune their
locator ids alongside the container's own entry.
2026-05-12 04:37:09 +00:00
Glary-Bot
5e8defb166 fix: clear nodeOutputStore entries on node removal
Pressing undo to remove a node left preview images on screen because
nodeOutputStore had no onNodeRemoved listener. Outputs keyed by the
removed node's locator id stayed in the store, and any future node
that reused the same id inherited the stale preview.

Adds installNodeOutputClearingHooks composable, modeled on
useErrorClearingHooks, that prunes the store at onNodeRemoved time
using a locator id derived from the graph the hook is installed on
(node.graph is nulled before the callback fires).
2026-05-12 00:00:17 +00:00
Christian Byrne
74caeb0b0b fix: detect V1/V2 draft storage keys in new-user check (#11728)
## Problem

`checkIsNewUser()` in `useNewUserService` only checked legacy pre-V1
localStorage keys (`workflow`, `Comfy.PreviousWorkflow`) to determine if
a user had prior workflow history. A returning user who had only ever
used the V1 or V2 draft persistence system would have neither of those
keys set, causing `isNewUser()` to return `true` and the getting-started
tab to appear in the workflow templates dialog after a settings reset.

## Solution

Extend the check to also cover:
- **V1 draft store keys**: `Comfy.Workflow.Drafts`,
`Comfy.Workflow.DraftOrder`
- **V2 draft index key**: `Comfy.Workflow.DraftIndex.v2:personal`

The `personal` scope is hardcoded for the V2 check because at the time
`checkIsNewUser()` runs, the cloud workspace ID (stored in
sessionStorage) may not be set yet. This is fine — any genuine new user
will have no personal workspace index regardless.

The original legacy keys are preserved for users who may still have them
from older installs.

## Tests

Added three new test cases covering V1 draft store keys, V1 draft order
key, and V2 draft index key.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11728-fix-detect-V1-V2-draft-storage-keys-in-new-user-check-3506d73d3650819ca4cfc8e83d95c258)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-11 20:16:04 +00:00
Alexander Brown
ced7c93e63 testing: Improve custom checks in .coderabbit.yaml (#12141)
Update coderabbit end-to-end check logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12141-testing-Improve-custom-checks-in-coderabbit-yaml-35d6d73d3650818e8be2f0b7d403683b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 19:05:48 +00:00
14 changed files with 523 additions and 143 deletions

View File

@@ -19,15 +19,26 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -0,0 +1,104 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
nodeId: string
): Promise<number> {
return await comfyPage.page.evaluate(
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
nodeId
)
}
async function seedNodeOutput(
comfyPage: ComfyPage,
nodeId: string
): Promise<void> {
await comfyPage.page.evaluate((id) => {
window.app!.nodeOutputs[id] = {
images: [
{ filename: 'seeded-preview.png', subfolder: '', type: 'output' }
]
}
}, nodeId)
}
test.describe('Node output cleanup on removal', { tag: '@workflow' }, () => {
test('Deleting a node clears its outputs from the store', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Pressing delete left previews behind because nodeOutputStore did not listen to onNodeRemoved'
})
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
expect(node).toBeTruthy()
const nodeId = String(node.id)
await seedNodeOutput(comfyPage, nodeId)
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
await node.click('title')
await comfyPage.page.keyboard.press('Delete')
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(0)
})
test('Undoing a node addition clears outputs produced for the removed node', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'Undo removed the node but left its preview rendered because the removal lifecycle did not invalidate nodeOutputStore'
})
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.page.keyboard.press('Control+a')
const addedNodeId = await comfyPage.page.evaluate(() => {
const litegraph = window.LiteGraph
if (!litegraph) throw new Error('LiteGraph is not available on window')
const graph = window.app!.graph
const registered = litegraph.registered_node_types
const typeName = registered['LoadImage']
? 'LoadImage'
: registered['PreviewImage']
? 'PreviewImage'
: undefined
if (!typeName) {
throw new Error('No suitable node type registered for the test')
}
const node = litegraph.createNode(typeName)
if (!node) throw new Error('Failed to create test node')
graph.add(node)
return String(node.id)
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
await seedNodeOutput(comfyPage, addedNodeId)
await expect
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
.toBe(1)
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.keyboard.undo()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount)
await expect
.poll(() => getNodeOutputImageCount(comfyPage, addedNodeId))
.toBe(0)
})
})

View File

@@ -596,7 +596,8 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
}
}
watch(selectedNavItem, () => coordinateNavAndSort('nav'), { immediate: true })
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
watch(sortBy, () => coordinateNavAndSort('sort'))
// Convert between string array and object array for MultiSelect component

View File

@@ -143,6 +143,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -249,11 +250,16 @@ const vueNodeLifecycle = useVueNodeLifecycle()
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
let cleanupErrorHooks: (() => void) | null = null
let cleanupNodeOutputHooks: (() => void) | null = null
watch(
() => canvasStore.currentGraph,
(graph) => {
cleanupErrorHooks?.()
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
cleanupNodeOutputHooks?.()
cleanupNodeOutputHooks = graph
? installNodeOutputClearingHooks(graph)
: null
}
)
@@ -538,6 +544,9 @@ onMounted(async () => {
// Install error-clearing hooks on the initial graph
if (comfyApp.canvas?.graph) {
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
cleanupNodeOutputHooks = installNodeOutputClearingHooks(
comfyApp.canvas.graph
)
}
vueNodeLifecycle.setupEmptyGraphListener()
@@ -597,6 +606,8 @@ onMounted(async () => {
onUnmounted(() => {
cleanupErrorHooks?.()
cleanupErrorHooks = null
cleanupNodeOutputHooks?.()
cleanupNodeOutputHooks = null
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {

View File

@@ -0,0 +1,208 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installNodeOutputClearingHooks } from '@/composables/graph/useNodeOutputClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
function seedOutputForLocator(locatorId: string) {
app.nodeOutputs[locatorId] = {
images: [{ filename: 'preview.png', type: 'output', subfolder: '' }]
}
}
describe('installNodeOutputClearingHooks', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('removes outputs for a root-level node when it is removed from the graph', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const node = new LGraphNode('LoadImage')
graph.add(node)
const locatorId = String(node.id)
seedOutputForLocator(locatorId)
expect(app.nodeOutputs[locatorId]).toBeDefined()
installNodeOutputClearingHooks(graph)
graph.remove(node)
expect(app.nodeOutputs[locatorId]).toBeUndefined()
expect(useNodeOutputStore().nodeOutputs[locatorId]).toBeUndefined()
})
it('removes outputs for a subgraph interior node using subgraphUuid:nodeId locator', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
seedOutputForLocator(interiorLocator)
expect(app.nodeOutputs[interiorLocator]).toBeDefined()
installNodeOutputClearingHooks(subgraph)
subgraph.remove(interiorNode)
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
})
it('does not affect outputs for other nodes that remain in the graph', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const removed = new LGraphNode('LoadImage')
const kept = new LGraphNode('LoadImage')
graph.add(removed)
graph.add(kept)
const removedLocator = String(removed.id)
const keptLocator = String(kept.id)
seedOutputForLocator(removedLocator)
seedOutputForLocator(keptLocator)
installNodeOutputClearingHooks(graph)
graph.remove(removed)
expect(app.nodeOutputs[removedLocator]).toBeUndefined()
expect(app.nodeOutputs[keptLocator]).toBeDefined()
})
it('chains with existing onNodeRemoved callbacks', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
let calledWith: LGraphNode | undefined
graph.onNodeRemoved = (node) => {
calledWith = node
}
const node = new LGraphNode('LoadImage')
graph.add(node)
seedOutputForLocator(String(node.id))
installNodeOutputClearingHooks(graph)
graph.remove(node)
expect(calledWith).toBe(node)
expect(app.nodeOutputs[String(node.id)]).toBeUndefined()
})
it('restores original onNodeRemoved when cleanup is called', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const original = () => undefined
graph.onNodeRemoved = original
const cleanup = installNodeOutputClearingHooks(graph)
expect(graph.onNodeRemoved).not.toBe(original)
cleanup()
expect(graph.onNodeRemoved).toBe(original)
})
it('clears interior node outputs when a subgraph container is removed from the root graph', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const subgraphNodeLocator = String(subgraphNode.id)
const interiorLocator = `${subgraph.id}:${interiorNode.id}`
seedOutputForLocator(subgraphNodeLocator)
seedOutputForLocator(interiorLocator)
installNodeOutputClearingHooks(rootGraph)
rootGraph.remove(subgraphNode)
expect(app.nodeOutputs[subgraphNodeLocator]).toBeUndefined()
expect(app.nodeOutputs[interiorLocator]).toBeUndefined()
})
it('also prunes the active workflow change tracker output cache so undo cannot resurrect the entry', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const node = new LGraphNode('LoadImage')
graph.add(node)
const locator = String(node.id)
seedOutputForLocator(locator)
const trackerCache: Record<string, unknown> = {
[locator]: { images: [{ filename: 'preview.png' }] }
}
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
changeTracker: { nodeOutputs: trackerCache }
} as never)
installNodeOutputClearingHooks(graph)
graph.remove(node)
expect(app.nodeOutputs[locator]).toBeUndefined()
expect(trackerCache[locator]).toBeUndefined()
})
it('preserves the tracker cache during workflow tab switch teardown', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const node = new LGraphNode('LoadImage')
graph.add(node)
const locator = String(node.id)
seedOutputForLocator(locator)
const trackerCache: Record<string, unknown> = {
[locator]: { images: [{ filename: 'preview.png' }] }
}
vi.spyOn(useWorkflowStore(), 'activeWorkflow', 'get').mockReturnValue({
changeTracker: { nodeOutputs: trackerCache, _restoringState: false }
} as never)
installNodeOutputClearingHooks(graph)
ChangeTracker.isLoadingGraph = true
try {
graph.remove(node)
} finally {
ChangeTracker.isLoadingGraph = false
}
expect(trackerCache[locator]).toBeDefined()
})
it('does not throw when the removal hook fires for an already-cleared node', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const node = new LGraphNode('LoadImage')
graph.add(node)
const locator = String(node.id)
seedOutputForLocator(locator)
installNodeOutputClearingHooks(graph)
graph.remove(node)
expect(() => graph.onNodeRemoved?.(node)).not.toThrow()
expect(app.nodeOutputs[locator]).toBeUndefined()
})
})

View File

@@ -0,0 +1,68 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getExecutionIdForNodeInGraph } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
function isTabSwitchTeardown(): boolean {
const tracker = useWorkflowStore().activeWorkflow?.changeTracker
return ChangeTracker.isLoadingGraph && !tracker?._restoringState
}
function dropTrackerCacheEntry(execId: string) {
if (isTabSwitchTeardown()) return
const tracked = useWorkflowStore().activeWorkflow?.changeTracker?.nodeOutputs
if (tracked) delete tracked[execId]
}
function clearInteriorOutputs(
subgraphNode: SubgraphNode,
execIdPrefix: string
) {
const subgraph: Subgraph | undefined = subgraphNode.subgraph
if (!subgraph) return
const store = useNodeOutputStore()
for (const interior of subgraph.nodes) {
store.removeOutputsByLocatorId(`${subgraph.id}:${interior.id}`)
const interiorExecId = `${execIdPrefix}:${interior.id}`
dropTrackerCacheEntry(interiorExecId)
if (interior.isSubgraphNode()) {
clearInteriorOutputs(interior, interiorExecId)
}
}
}
export function installNodeOutputClearingHooks(graph: LGraph): () => void {
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
try {
const store = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const locatorId = isSubgraph(graph)
? nodeIdToNodeLocatorId(node.id, graph)
: String(node.id)
store.removeOutputsByLocatorId(locatorId)
const execId = app.rootGraph
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
: String(node.id)
dropTrackerCacheEntry(execId)
if (node.isSubgraphNode()) {
clearInteriorOutputs(node, execId)
}
} finally {
originalOnNodeRemoved?.call(this, node)
}
}
return () => {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -219,66 +219,5 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('newUserDefaultTemplateTab returns dev override value', () => {
localStorage.setItem(
'ff:new_user_default_template_tab',
JSON.stringify('popular')
)
const { flags } = useFeatureFlags()
expect(flags.newUserDefaultTemplateTab).toBe('popular')
})
it('newUserDefaultTemplateTab trims whitespace', () => {
localStorage.setItem(
'ff:new_user_default_template_tab',
JSON.stringify(' popular ')
)
const { flags } = useFeatureFlags()
expect(flags.newUserDefaultTemplateTab).toBe('popular')
})
it.each(['', ' ', '\t\n'])(
'newUserDefaultTemplateTab normalizes %j to undefined',
(raw) => {
localStorage.setItem(
'ff:new_user_default_template_tab',
JSON.stringify(raw)
)
const { flags } = useFeatureFlags()
expect(flags.newUserDefaultTemplateTab).toBeUndefined()
}
)
it('newUserDefaultTemplateTab returns undefined when unset', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.newUserDefaultTemplateTab).toBeUndefined()
})
it('newUserDefaultTemplateTab falls through to server feature when remoteConfig is blank', async () => {
const { remoteConfig } =
await import('@/platform/remoteConfig/remoteConfig')
const previous = remoteConfig.value
remoteConfig.value = { new_user_default_template_tab: ' ' }
vi.mocked(api.getServerFeature).mockImplementation((path) => {
if (path === ServerFeatureFlag.NEW_USER_DEFAULT_TEMPLATE_TAB)
return 'popular'
return undefined
})
try {
const { flags } = useFeatureFlags()
expect(flags.newUserDefaultTemplateTab).toBe('popular')
} finally {
remoteConfig.value = previous
}
})
})
})

View File

@@ -27,8 +27,7 @@ export enum ServerFeatureFlag {
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
NEW_USER_DEFAULT_TEMPLATE_TAB = 'new_user_default_template_tab'
SHOW_SIGNIN_BUTTON = 'show_signin_button'
}
/**
@@ -164,22 +163,6 @@ export function useFeatureFlags() {
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
undefined
)
},
/**
* Template category id shown by default when the template selector
* opens for a new user during onboarding. Used for A/B testing the
* onboarding tab via PostHog feature flags. Returns `undefined` when
* unset so callers fall back to the built-in default.
*/
get newUserDefaultTemplateTab(): string | undefined {
const remote = remoteConfig.value.new_user_default_template_tab?.trim()
const value = resolveFlag<string | undefined>(
ServerFeatureFlag.NEW_USER_DEFAULT_TEMPLATE_TAB,
remote ? remote : undefined,
undefined
)
const normalized = value?.trim()
return normalized ? normalized : undefined
}
})

View File

@@ -16,10 +16,6 @@ const mockTelemetry = vi.hoisted(() => ({
trackTemplateLibraryOpened: vi.fn()
}))
const mockFlags = vi.hoisted(() => ({
newUserDefaultTemplateTab: undefined as string | undefined
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
@@ -36,10 +32,6 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mockTelemetry
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock(
'@/components/custom/widget/WorkflowTemplateSelectorDialog.vue',
() => ({
@@ -52,7 +44,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
describe('useWorkflowTemplateSelectorDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.newUserDefaultTemplateTab = undefined
})
describe('show', () => {
@@ -101,38 +92,6 @@ describe('useWorkflowTemplateSelectorDialog', () => {
)
})
it('uses feature flag override for new users when set', () => {
mockNewUserService.isNewUser.mockReturnValue(true)
mockFlags.newUserDefaultTemplateTab = 'popular'
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'popular'
})
})
)
})
it('ignores feature flag override for non-new users', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
mockFlags.newUserDefaultTemplateTab = 'popular'
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'all'
})
})
)
})
it('uses explicit initialCategory when provided', () => {
mockNewUserService.isNewUser.mockReturnValue(true)

View File

@@ -1,5 +1,4 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
@@ -13,16 +12,11 @@ export const useWorkflowTemplateSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const newUserService = useNewUserService()
const { flags } = useFeatureFlags()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function newUserDefaultCategory() {
return flags.newUserDefaultTemplateTab ?? GETTING_STARTED_CATEGORY_ID
}
function show(
source: TemplateLibraryMetadata['source'] = 'command',
options?: { initialCategory?: string; afterClose?: () => void }
@@ -31,7 +25,7 @@ export const useWorkflowTemplateSelectorDialog = () => {
const initialCategory =
options?.initialCategory ??
(newUserService.isNewUser() ? newUserDefaultCategory() : 'all')
(newUserService.isNewUser() ? GETTING_STARTED_CATEGORY_ID : 'all')
dialogService.showLayoutDialog({
key: DIALOG_KEY,

View File

@@ -104,5 +104,4 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
sentry_dsn?: string
new_user_default_template_tab?: string
}

View File

@@ -26,9 +26,6 @@ vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
//@ts-expect-error Define global for the test
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
import { useNewUserService } from '@/services/useNewUserService'
describe('useNewUserService', () => {
@@ -120,6 +117,73 @@ describe('useNewUserService', () => {
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when V1 draft store keys exist', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.Drafts') return '{}'
return null
})
await service.initializeIfNewUser()
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when V1 draft order key exists', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.DraftOrder') return '[]'
return null
})
await service.initializeIfNewUser()
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when V2 draft index has entries', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
return '{"v":2,"updatedAt":1,"order":["abc"],"entries":{"abc":{"path":"workflows/Untitled.json","name":"Untitled","isTemporary":true,"updatedAt":1}}}'
return null
})
await service.initializeIfNewUser()
expect(service.isNewUser()).toBe(false)
})
it('should identify new user when V2 draft index exists but is empty', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
return '{"v":2,"updatedAt":1,"order":[],"entries":{}}'
return null
})
await service.initializeIfNewUser()
expect(service.isNewUser()).toBe(true)
})
it('should identify new user when V2 draft index is malformed', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.DraftIndex.v2:personal') return 'not json'
return null
})
await service.initializeIfNewUser()
expect(service.isNewUser()).toBe(true)
})
it('should identify new user when tutorial is explicitly false', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {

View File

@@ -2,6 +2,24 @@ import { ref, shallowRef } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useSettingStore } from '@/platform/settings/settingStore'
function hasV2DraftHistory(raw: string | null): boolean {
if (!raw) return false
try {
const parsed = JSON.parse(raw) as {
order?: unknown
entries?: unknown
}
const orderLength = Array.isArray(parsed.order) ? parsed.order.length : 0
const entriesCount =
parsed.entries && typeof parsed.entries === 'object'
? Object.keys(parsed.entries as Record<string, unknown>).length
: 0
return orderLength > 0 || entriesCount > 0
} catch {
return false
}
}
function _useNewUserService() {
const settingStore = useSettingStore()
const pendingCallbacks = shallowRef<Array<() => Promise<void>>>([])
@@ -18,12 +36,32 @@ function _useNewUserService() {
const isNewUserSettings =
Object.keys(settingStore.settingValues).length === 0 ||
!settingStore.get('Comfy.TutorialCompleted')
const hasNoWorkflow = !localStorage.getItem('workflow')
const hasNoPreviousWorkflow = !localStorage.getItem(
'Comfy.PreviousWorkflow'
// Legacy keys (pre-V1 and V1 persistence)
const hasNoLegacyWorkflow =
!localStorage.getItem('workflow') &&
!localStorage.getItem('Comfy.PreviousWorkflow')
// V1 draft store keys
const hasNoV1Drafts =
!localStorage.getItem('Comfy.Workflow.Drafts') &&
!localStorage.getItem('Comfy.Workflow.DraftOrder')
// V2 draft index key (scoped to personal workspace; cloud workspace id
// comes from sessionStorage which may not be set yet at this point).
// Check for actual draft history rather than key existence: an empty
// index is written by `migrateV1toV2()` for genuine new users during
// startup, so key presence alone is not evidence of prior usage.
const hasNoV2DraftIndex = !hasV2DraftHistory(
localStorage.getItem('Comfy.Workflow.DraftIndex.v2:personal')
)
return isNewUserSettings && hasNoWorkflow && hasNoPreviousWorkflow
return (
isNewUserSettings &&
hasNoLegacyWorkflow &&
hasNoV1Drafts &&
hasNoV2DraftIndex
)
}
async function registerInitCallback(callback: () => Promise<void>) {

View File

@@ -499,6 +499,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeSubgraphPreviews,
removeNodeOutputs,
removeNodeOutputsForNode,
removeOutputsByLocatorId,
snapshotOutputs,
restoreOutputs,
resetAllOutputsAndPreviews,