Compare commits

...

9 Commits

Author SHA1 Message Date
GitHub Action
933cf6a205 [automated] Apply ESLint and Oxfmt fixes 2026-06-26 00:02:29 +00:00
bymyself
b56c21e2cd refactor: replace let mockQueryParams with vi.hoisted() getter in test
Replaces the module-level let mockQueryParams with a vi.hoisted() routeMocks
object using a getter on the useRoute mock. This matches the pattern from
useWorkflowPersistenceV2.test.ts and ensures re-reads of route.query inside
the composable pick up assignments made after the mock factory has been called.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11457#discussion_r3263262467
2026-06-25 23:58:23 +00:00
bymyself
71a1e3b637 refactor: rename isReady to hasAttempted in useTemplateUrlLoader
isReady is set true on success, failure, and exception — it signals
'load attempt completed', not 'result is ready to consume'. Renaming to
hasAttempted makes the semantics unambiguous for consumers.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11457#discussion_r3263234149
2026-06-25 23:58:23 +00:00
bymyself
8132274edc refactor: type JSON.parse result as unknown in readFromStorage
Annotates the parsed value from JSON.parse as unknown instead of any,
relying on the isValidQueryRecord type guard for narrowing. More idiomatic
per docs/guidance/typescript.md.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11457#discussion_r3263234147
2026-06-25 23:58:23 +00:00
bymyself
12ab97bed8 refactor: expose hasPreservedQuery helper and use it in hasTemplateIntent
Replaces the hardcoded sessionStorage key 'Comfy.PreservedQuery.template'
in hasTemplateIntent() with a call to the new hasPreservedQuery() helper
exported from preservedQueryManager. This ensures the namespace abstraction
is not bypassed and the key derivation is owned by one place.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11457#discussion_r3263234145
2026-06-25 23:58:23 +00:00
Glary-Bot
73ae712ede fix: address review — use preserved-query-aware hasTemplateIntent, reset refs on retry 2026-06-25 23:58:17 +00:00
Glary-Bot
45474e7e1b test: add persistence and E2E tests for template URL loading spinner 2026-06-25 23:58:05 +00:00
Glary-Bot
81f290375c feat: add loading state refs to useTemplateUrlLoader and manage spinner during template load 2026-06-25 23:57:50 +00:00
Glary-Bot
435dcf1ad5 test: add loading state refs tests for useTemplateUrlLoader 2026-06-25 23:57:06 +00:00
5 changed files with 327 additions and 33 deletions

View File

@@ -0,0 +1,114 @@
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MOCK_TEMPLATE_INDEX: WorkflowTemplates[] = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'test_template',
title: 'Test Template',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A test template for URL loading.'
}
]
}
]
const MOCK_WORKFLOW_JSON = {
last_node_id: 2,
last_link_id: 1,
nodes: [
{
id: 1,
type: 'KSampler',
pos: [100, 100],
size: [300, 200],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [1] }],
properties: {}
},
{
id: 2,
type: 'EmptyLatentImage',
pos: [500, 100],
size: [300, 200],
inputs: [{ name: 'LATENT', type: 'LATENT', link: 1 }],
properties: {}
}
],
links: [[1, 1, 0, 2, 0, 'LATENT']],
groups: [],
config: {},
extra: {},
version: 0.4
}
test.describe('Template URL loading spinner', { tag: ['@workflow'] }, () => {
test('shows spinner while loading template from URL param', async ({
comfyPage
}) => {
const blockUIMask = comfyPage.page.locator('.p-blockui-mask')
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(MOCK_TEMPLATE_INDEX),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
let resolveTemplate: (() => void) | undefined
const templateDelay = new Promise<void>((resolve) => {
resolveTemplate = resolve
})
await comfyPage.page.route(
'**/templates/test_template.json',
async (route) => {
await templateDelay
await route.fulfill({
status: 200,
body: JSON.stringify(MOCK_WORKFLOW_JSON),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
)
await comfyPage.page.goto(`${comfyPage.url}?template=test_template`)
await expect(blockUIMask).toBeVisible({ timeout: 10_000 })
resolveTemplate?.()
await expect(blockUIMask).toBeHidden({ timeout: 30_000 })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
})
test('dismisses spinner normally when no template param', async ({
comfyPage
}) => {
const blockUIMask = comfyPage.page.locator('.p-blockui-mask')
await comfyPage.page.goto(comfyPage.url)
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect(blockUIMask).toBeHidden({ timeout: 30_000 })
})
})

View File

@@ -23,7 +23,7 @@ const readFromStorage = (namespace: string): Record<string, string> | null => {
const raw = sessionStorage.getItem(getStorageKey(namespace))
if (!raw) return null
const parsed = JSON.parse(raw)
const parsed: unknown = JSON.parse(raw)
if (!isValidQueryRecord(parsed)) {
console.warn('[preservedQuery] invalid storage format')
sessionStorage.removeItem(getStorageKey(namespace))
@@ -114,6 +114,11 @@ export const mergePreservedQueryIntoQuery = (
return changed ? nextQuery : undefined
}
export const hasPreservedQuery = (namespace: string): boolean => {
if (preservedQueries.has(namespace)) return true
return readFromStorage(namespace) !== null
}
export const clearPreservedQuery = (namespace: string) => {
if (!preservedQueries.has(namespace)) return
preservedQueries.delete(namespace)

View File

@@ -17,6 +17,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
hasPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
@@ -222,13 +223,23 @@ export function useWorkflowPersistenceV2() {
}
}
const loadTemplateFromUrlIfPresent = async () => {
const hasTemplateIntent = (): boolean => {
if (route.query.template && typeof route.query.template === 'string') {
return true
}
return hasPreservedQuery(TEMPLATE_NAMESPACE)
}
const loadTemplateFromUrlIfPresent = async (): Promise<boolean> => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
return true
}
return false
}
const loadSharedWorkflowFromUrlIfPresent = async () => {
@@ -351,6 +362,7 @@ export function useWorkflowPersistenceV2() {
}
return {
hasTemplateIntent,
initializeWorkflow,
loadSharedWorkflowFromUrlIfPresent,
loadTemplateFromUrlIfPresent,

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReadonly } from 'vue'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
@@ -17,13 +18,17 @@ const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn()
}))
// Mock vue-router
let mockQueryParams: Record<string, string | string[] | undefined> = {}
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, string | string[] | undefined>
}))
const mockRouterReplace = vi.fn()
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
query: mockQueryParams
get query() {
return routeMocks.query
}
})),
useRouter: vi.fn(() => ({
replace: mockRouterReplace
@@ -83,12 +88,12 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
routeMocks.query = {}
mockCanvasStore.linearMode = false
})
it('does not load template when no query param present', () => {
mockQueryParams = {}
routeMocks.query = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -98,7 +103,7 @@ describe('useTemplateUrlLoader', () => {
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }
routeMocks.query = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -112,7 +117,7 @@ describe('useTemplateUrlLoader', () => {
})
it('uses default source when source param is not provided', async () => {
mockQueryParams = { template: 'flux_simple' }
routeMocks.query = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -124,7 +129,7 @@ describe('useTemplateUrlLoader', () => {
})
it('uses custom source when source param is provided', async () => {
mockQueryParams = { template: 'custom-template', source: 'custom-module' }
routeMocks.query = { template: 'custom-template', source: 'custom-module' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -136,7 +141,7 @@ describe('useTemplateUrlLoader', () => {
})
it('shows error toast when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template' }
routeMocks.query = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
@@ -151,7 +156,7 @@ describe('useTemplateUrlLoader', () => {
it('handles array query params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = { template: ['first', 'second'] }
routeMocks.query = { template: ['first', 'second'] }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -162,7 +167,7 @@ describe('useTemplateUrlLoader', () => {
it('rejects invalid template parameter with special characters', () => {
// Test path traversal attempt
mockQueryParams = { template: '../../../etc/passwd' }
routeMocks.query = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -172,7 +177,7 @@ describe('useTemplateUrlLoader', () => {
})
it('rejects invalid template parameter with slash', () => {
mockQueryParams = { template: 'path/to/template' }
routeMocks.query = { template: 'path/to/template' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -192,7 +197,7 @@ describe('useTemplateUrlLoader', () => {
for (const template of validTemplates) {
vi.clearAllMocks()
mockQueryParams = { template }
routeMocks.query = { template }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -202,7 +207,7 @@ describe('useTemplateUrlLoader', () => {
})
it('rejects invalid source parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', source: '../malicious' }
routeMocks.query = { template: 'flux_simple', source: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -216,7 +221,7 @@ describe('useTemplateUrlLoader', () => {
for (const source of validSources) {
vi.clearAllMocks()
mockQueryParams = { template: 'flux_simple', source }
routeMocks.query = { template: 'flux_simple', source }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -229,7 +234,7 @@ describe('useTemplateUrlLoader', () => {
})
it('shows error toast when exception is thrown', async () => {
mockQueryParams = { template: 'flux_simple' }
routeMocks.query = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
@@ -243,7 +248,7 @@ describe('useTemplateUrlLoader', () => {
})
it('removes template params from URL after successful load', async () => {
mockQueryParams = {
routeMocks.query = {
template: 'flux_simple',
source: 'custom',
mode: 'linear',
@@ -259,7 +264,7 @@ describe('useTemplateUrlLoader', () => {
})
it('removes template params from URL even on error', async () => {
mockQueryParams = { template: 'invalid', source: 'custom', other: 'param' }
routeMocks.query = { template: 'invalid', source: 'custom', other: 'param' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
@@ -271,7 +276,7 @@ describe('useTemplateUrlLoader', () => {
})
it('removes template params from URL even on exception', async () => {
mockQueryParams = { template: 'flux_simple', other: 'param' }
routeMocks.query = { template: 'flux_simple', other: 'param' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
@@ -283,7 +288,7 @@ describe('useTemplateUrlLoader', () => {
})
it('sets linear mode when mode=linear and template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
routeMocks.query = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -296,7 +301,7 @@ describe('useTemplateUrlLoader', () => {
})
it('does not set linear mode when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
routeMocks.query = { template: 'invalid-template', mode: 'linear' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
@@ -306,7 +311,7 @@ describe('useTemplateUrlLoader', () => {
})
it('does not set linear mode when mode parameter is not linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
routeMocks.query = { template: 'flux_simple', mode: 'graph' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -319,7 +324,7 @@ describe('useTemplateUrlLoader', () => {
})
it('rejects invalid mode parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
routeMocks.query = { template: 'flux_simple', mode: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
@@ -329,7 +334,7 @@ describe('useTemplateUrlLoader', () => {
it('handles array mode params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = {
routeMocks.query = {
template: 'flux_simple',
mode: ['linear', 'graph']
}
@@ -343,7 +348,7 @@ describe('useTemplateUrlLoader', () => {
it('warns about unsupported mode values but continues loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
routeMocks.query = { template: 'flux_simple', mode: 'unsupported' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -361,7 +366,7 @@ describe('useTemplateUrlLoader', () => {
})
it('accepts supported mode parameter: linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
routeMocks.query = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -381,7 +386,7 @@ describe('useTemplateUrlLoader', () => {
vi.clearAllMocks()
consoleSpy.mockClear()
mockCanvasStore.linearMode = false
mockQueryParams = { template: 'flux_simple', mode }
routeMocks.query = { template: 'flux_simple', mode }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
@@ -398,4 +403,146 @@ describe('useTemplateUrlLoader', () => {
consoleSpy.mockRestore()
})
describe('loading state refs', () => {
it('returns readonly refs for isLoading, error, and hasAttempted', () => {
const { isLoading, error, hasAttempted } = useTemplateUrlLoader()
expect(isReadonly(isLoading)).toBe(true)
expect(isReadonly(error)).toBe(true)
expect(isReadonly(hasAttempted)).toBe(true)
})
it('transitions refs through success lifecycle', async () => {
routeMocks.query = { template: 'flux_simple' }
let resolveLoadTemplates: (() => void) | undefined
mockLoadTemplates.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveLoadTemplates = resolve
})
)
const { loadTemplateFromUrl, isLoading, error, hasAttempted } =
useTemplateUrlLoader()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
const loadPromise = loadTemplateFromUrl()
expect(isLoading.value).toBe(true)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
resolveLoadTemplates?.()
await loadPromise
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(true)
})
it('sets hasAttempted after failed template load without setting error', async () => {
routeMocks.query = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl, isLoading, error, hasAttempted } =
useTemplateUrlLoader()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(true)
})
it('sets error and hasAttempted when load throws', async () => {
routeMocks.query = { template: 'flux_simple' }
const thrownError = new Error('Network error')
mockLoadTemplates.mockRejectedValueOnce(thrownError)
const { loadTemplateFromUrl, isLoading, error, hasAttempted } =
useTemplateUrlLoader()
const loadPromise = loadTemplateFromUrl()
expect(isLoading.value).toBe(true)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
await loadPromise
expect(isLoading.value).toBe(false)
expect(error.value).toBe(thrownError)
expect(hasAttempted.value).toBe(true)
})
it('keeps refs unchanged when template param is missing', async () => {
routeMocks.query = {}
const { loadTemplateFromUrl, isLoading, error, hasAttempted } =
useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
expect(mockLoadTemplates).not.toHaveBeenCalled()
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('keeps refs unchanged for invalid params before async work', async () => {
const invalidQueries: Record<string, string>[] = [
{ template: '../../../etc/passwd' },
{ template: 'flux_simple', source: '../malicious' },
{ template: 'flux_simple', mode: '../malicious' }
]
for (const query of invalidQueries) {
vi.clearAllMocks()
routeMocks.query = query
const { loadTemplateFromUrl, isLoading, error, hasAttempted } =
useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
expect(mockLoadTemplates).not.toHaveBeenCalled()
}
})
it('resets stale state on retry after error', async () => {
routeMocks.query = { template: 'flux_simple' }
const thrownError = new Error('Network error')
mockLoadTemplates.mockRejectedValueOnce(thrownError)
const { loadTemplateFromUrl, error, hasAttempted } =
useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(error.value).toBe(thrownError)
expect(hasAttempted.value).toBe(true)
mockLoadTemplates.mockResolvedValueOnce(true)
const retryPromise = loadTemplateFromUrl()
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(false)
await retryPromise
expect(error.value).toBe(null)
expect(hasAttempted.value).toBe(true)
})
})
})

View File

@@ -1,4 +1,5 @@
import { useToast } from 'primevue/usetoast'
import { readonly, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -33,6 +34,10 @@ export function useTemplateUrlLoader() {
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
const isLoading = ref(false)
const error = shallowRef<Error | null>(null)
const hasAttempted = ref(false)
/**
* Validates parameter format to prevent path traversal and injection attacks
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
@@ -65,6 +70,9 @@ export function useTemplateUrlLoader() {
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async () => {
error.value = null
hasAttempted.value = false
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
@@ -105,6 +113,8 @@ export function useTemplateUrlLoader() {
)
}
isLoading.value = true
try {
await templateWorkflows.loadTemplates()
@@ -122,14 +132,15 @@ export function useTemplateUrlLoader() {
})
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
} catch (error) {
} catch (e) {
const caught = e instanceof Error ? e : new Error(String(e))
error.value = caught
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
error
caught
)
toast.add({
severity: 'error',
@@ -137,12 +148,17 @@ export function useTemplateUrlLoader() {
detail: t('g.errorLoadingTemplate')
})
} finally {
isLoading.value = false
hasAttempted.value = true
cleanupUrlParams()
clearPreservedQuery(TEMPLATE_NAMESPACE)
}
}
return {
loadTemplateFromUrl
loadTemplateFromUrl,
isLoading: readonly(isLoading),
error: readonly(error),
hasAttempted: readonly(hasAttempted)
}
}