Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
Johnpaul Chiwetelu 90a701dd67 Road to No Explicit Any Part 11 (#8565)
## Summary

This PR removes `any` types from widgets, services, stores, and test
files, replacing them with proper TypeScript types.

### Key Changes

#### Type Safety Improvements
- Replaced `any` with `unknown`, explicit types, or proper interfaces
across widgets and services
- Added proper type imports (TgpuRoot, Point, StyleValue, etc.)
- Created typed interfaces (NumericWidgetOptions, TestWindow,
ImportFailureDetail, etc.)
- Fixed function return types to be non-nullable where appropriate
- Added type guards and null checks instead of non-null assertions
- Used `ComponentProps` from vue-component-type-helpers for component
testing

#### Widget System
- Added index signature to IWidgetOptions for Record compatibility
- Centralized disabled logic in WidgetInputNumberInput
- Moved template type assertions to computed properties
- Fixed ComboWidget getOptionLabel type assertions
- Improved remote widget type handling with runtime checks

#### Services & Stores
- Fixed getOrCreateViewer to return non-nullable values
- Updated addNodeOnGraph to use specific options type `{ pos?: Point }`
- Added proper type assertions for settings store retrieval
- Fixed executionIdToCurrentId return type (string | undefined)

#### Test Infrastructure
- Exported GraphOrSubgraph from litegraph barrel to avoid circular
dependencies
- Updated test fixtures with proper TypeScript types (TestInfo,
LGraphNode)
- Replaced loose Record types with ComponentProps in tests
- Added proper error handling in WebSocket fixture

#### Code Organization
- Created shared i18n-types module for locale data types
- Made ImportFailureDetail non-exported (internal use only)
- Added @public JSDoc tag to ElectronWindow type
- Fixed console.log usage in scripts to use allowed methods

### Files Changed

**Widgets & Components:**
-
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
- src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
- src/lib/litegraph/src/widgets/ComboWidget.ts
- src/lib/litegraph/src/types/widgets.ts
- src/components/common/LazyImage.vue
- src/components/load3d/Load3dViewerContent.vue

**Services & Stores:**
- src/services/litegraphService.ts
- src/services/load3dService.ts
- src/services/colorPaletteService.ts
- src/stores/maskEditorStore.ts
- src/stores/nodeDefStore.ts
- src/platform/settings/settingStore.ts
- src/platform/workflow/management/stores/workflowStore.ts

**Composables & Utils:**
- src/composables/node/useWatchWidget.ts
- src/composables/useCanvasDrop.ts
- src/utils/widgetPropFilter.ts
- src/utils/queueDisplay.ts
- src/utils/envUtil.ts

**Test Files:**
- browser_tests/fixtures/ComfyPage.ts
- browser_tests/fixtures/ws.ts
- browser_tests/tests/actionbar.spec.ts
-
src/workbench/extensions/manager/components/manager/skeleton/PackCardGridSkeleton.test.ts
- src/lib/litegraph/src/subgraph/subgraphUtils.test.ts
- src/components/rightSidePanel/shared.test.ts
- src/platform/cloud/subscription/composables/useSubscription.test.ts
-
src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts

**Scripts & Types:**
- scripts/i18n-types.ts (new shared module)
- scripts/diff-i18n.ts
- scripts/check-unused-i18n-keys.ts
- src/workbench/extensions/manager/types/conflictDetectionTypes.ts
- src/types/algoliaTypes.ts
- src/types/simplifiedWidget.ts

**Infrastructure:**
- src/lib/litegraph/src/litegraph.ts (added GraphOrSubgraph export)
- src/lib/litegraph/src/infrastructure/CustomEventTarget.ts
- src/platform/assets/services/assetService.ts

**Stories:**
- apps/desktop-ui/src/views/InstallView.stories.ts
- src/components/queue/job/JobDetailsPopover.stories.ts

**Extension Manager:**
- src/workbench/extensions/manager/composables/useConflictDetection.ts
- src/workbench/extensions/manager/composables/useManagerQueue.ts
- src/workbench/extensions/manager/services/comfyManagerService.ts
- src/workbench/extensions/manager/utils/conflictMessageUtil.ts

### Testing

- [x] All TypeScript type checking passes (`pnpm typecheck`)
- [x] ESLint passes without errors (`pnpm lint`)
- [x] Format checks pass (`pnpm format:check`)
- [x] Knip (unused exports) passes (`pnpm knip`)
- [x] Pre-commit and pre-push hooks pass

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496
- Part 9: #8498
- Part 10: #8499

---------

Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-05 16:29:28 -08:00

299 lines
8.0 KiB
TypeScript

import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })
}
}
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data !== undefined &&
entry?.timestamp !== undefined &&
entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const authHeaders = await getAuthHeaders()
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout,
...authHeaders
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
remoteConfig: RemoteWidgetConfig
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T | T[]) => {
isLoaded = true
const nextValue =
Array.isArray(data) && data.length > 0 ? data[0] : undefined
widget.value = nextValue ?? (Array.isArray(data) ? defaultValue : data)
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data as T
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry))
return entry!.data as T
const currentEntry: CacheEntry<T> = (entry as
| CacheEntry<T>
| undefined) || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
}
onFulfilled?.()
})
.catch((err) => {
console.error(err)
})
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}