mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
refactor(subgraph): consolidate type guards and tighten review nits
- Extract canonical isWidgetValue helper to types/widgets.ts and dedupe copies in promotedWidgetView, promotionUtils, and SubgraphNode - Replace cast in proxyWidgetMigration.pickHostValue with isWidgetValue narrowing; treat invalid host payloads as holes - Replace 'getSlotFromWidget' duck-type guard in promoteWidget with instanceof LGraphNode narrowing - Add DEV-only console.warn in SubgraphNode.serialize() when host widgets contain non-promoted entries (per ADR 0009) - Extract LGraphTriggerActions tuple as single source of truth for the runtime Set and the LGraphTriggerAction type union - Replace in-place tuple slot mutation in appModeStore.updateInputConfig with splice replacing the whole entry - Drop dead setter on activeWidgets computed in SubgraphEditor.vue Amp-Thread-ID: https://ampcode.com/threads/T-019e2812-d683-710e-946f-9ddb9018ff5a Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -41,16 +41,10 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
|
||||
return [...getActivePromotedWidgets(node), ...getActivePreviewWidgets(node)]
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
updateActiveWidgets(value, activeWidgets.value)
|
||||
}
|
||||
const activeWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedWidgets(node), ...getActivePreviewWidgets(node)]
|
||||
})
|
||||
|
||||
const activePromotedWidgets = computed<WidgetItem[]>({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
/**
|
||||
@@ -206,7 +207,12 @@ function pickHostValue(
|
||||
) {
|
||||
return { value: undefined, isHole: true }
|
||||
}
|
||||
return { value: hostWidgetValues[index] as TWidgetValue, isHole: false }
|
||||
// Narrow rather than cast: a slot whose payload isn't a `TWidgetValue`
|
||||
// (e.g. a function or `null` from corrupted serialization) is treated as
|
||||
// a hole so the migration falls back to seeding from the source widget.
|
||||
const raw = hostWidgetValues[index]
|
||||
if (!isWidgetValue(raw)) return { value: undefined, isHole: true }
|
||||
return { value: raw, isHole: false }
|
||||
}
|
||||
|
||||
function findHostInputForLinkedSource(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
@@ -37,14 +38,6 @@ interface SubgraphSlotRef {
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isValueControlWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
|
||||
|
||||
@@ -2,12 +2,11 @@ import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
@@ -25,14 +24,6 @@ type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
@@ -335,28 +326,23 @@ export function promoteWidget(
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const source = toPromotionSource(node, widget)
|
||||
// Both downstream helpers (`promotePreviewViaExposure`,
|
||||
// `promoteValueWidgetViaSubgraphInput`) require the full `LGraphNode`
|
||||
// shape — a `Pick<...>` won't do. Narrow once with `instanceof` rather
|
||||
// than re-checking each call site with property guards + casts.
|
||||
if (!(node instanceof LGraphNode)) return
|
||||
for (const parent of parents) {
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
promotePreviewViaExposure(
|
||||
parent,
|
||||
node as LGraphNode,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
promotePreviewViaExposure(parent, node, source.sourceWidgetName)
|
||||
continue
|
||||
}
|
||||
if ('getSlotFromWidget' in node) {
|
||||
const result = promoteValueWidgetViaSubgraphInput(
|
||||
parent,
|
||||
node as LGraphNode,
|
||||
widget
|
||||
)
|
||||
if (!result.ok) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
level: 'warning',
|
||||
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
|
||||
})
|
||||
}
|
||||
const result = promoteValueWidgetViaSubgraphInput(parent, node, widget)
|
||||
if (!result.ok) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
level: 'warning',
|
||||
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
|
||||
})
|
||||
}
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
|
||||
@@ -76,6 +76,7 @@ import type {
|
||||
LGraphTriggerHandler,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
import { LGraphTriggerActions } from './types/graphTriggers'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExposedWidget,
|
||||
@@ -96,6 +97,9 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
/** Runtime allowlist for {@link LGraph.trigger}, derived from {@link LGraphTriggerActions}. */
|
||||
const validTriggerActions = new Set<LGraphTriggerAction>(LGraphTriggerActions)
|
||||
|
||||
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
||||
|
||||
/**
|
||||
@@ -1362,13 +1366,7 @@ export class LGraph
|
||||
): void
|
||||
trigger(action: string, param: unknown): void
|
||||
trigger(action: string, param: unknown) {
|
||||
const validEventTypes = new Set<LGraphTriggerAction>([
|
||||
'node:slot-links:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:property:changed',
|
||||
'node:slot-label:changed'
|
||||
])
|
||||
if (!validEventTypes.has(action as LGraphTriggerAction)) return
|
||||
if (!validTriggerActions.has(action as LGraphTriggerAction)) return
|
||||
if (!param || typeof param !== 'object') return
|
||||
|
||||
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
||||
|
||||
@@ -28,10 +28,8 @@ import type {
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createPromotedWidgetView,
|
||||
isPromotedWidgetView
|
||||
@@ -66,14 +64,6 @@ type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
|
||||
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
|
||||
|
||||
function isWidgetValue(value: unknown): value is TWidgetValue {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
|
||||
*/
|
||||
@@ -1154,6 +1144,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
serialized.properties = serializedProperties
|
||||
|
||||
// Per ADR 0009, host SubgraphNodes only carry promoted widgets — non-
|
||||
// promoted host widgets would be silently dropped here. Surface the
|
||||
// unexpected case in dev so a future custom subclass that adds bare
|
||||
// widgets isn't ignored without a trace.
|
||||
if (
|
||||
import.meta.env?.DEV &&
|
||||
this.widgets.some((w) => !isPromotedWidgetView(w))
|
||||
) {
|
||||
console.warn(
|
||||
`SubgraphNode ${this.id}: serialize() drops non-promoted host widgets ` +
|
||||
`(${this.widgets
|
||||
.filter((w) => !isPromotedWidgetView(w))
|
||||
.map((w) => w.name)
|
||||
.join(', ')}); ` +
|
||||
'expected only PromotedWidgetView instances per ADR 0009.'
|
||||
)
|
||||
}
|
||||
|
||||
const widgetValues = this.inputs.flatMap((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return []
|
||||
|
||||
@@ -35,7 +35,20 @@ export type LGraphTriggerEvent =
|
||||
| NodeSlotLinksChangedEvent
|
||||
| NodeSlotLabelChangedEvent
|
||||
|
||||
export type LGraphTriggerAction = LGraphTriggerEvent['type']
|
||||
/**
|
||||
* Single source of truth for actions accepted by `LGraph.trigger()`.
|
||||
* Both the runtime allowlist (`LGraphTriggerActions`) and the static type
|
||||
* (`LGraphTriggerAction`) derive from this tuple — adding a new action is
|
||||
* a one-place change. Keep in lockstep with {@link LGraphTriggerEvent}.
|
||||
*/
|
||||
export const LGraphTriggerActions = [
|
||||
'node:property:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:slot-links:changed',
|
||||
'node:slot-label:changed'
|
||||
] as const satisfies readonly LGraphTriggerEvent['type'][]
|
||||
|
||||
export type LGraphTriggerAction = (typeof LGraphTriggerActions)[number]
|
||||
|
||||
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
|
||||
LGraphTriggerEvent,
|
||||
|
||||
@@ -375,6 +375,21 @@ export interface IRangeWidget extends IBaseWidget<
|
||||
export type TWidgetType = IWidget['type']
|
||||
export type TWidgetValue = IWidget['value']
|
||||
|
||||
/**
|
||||
* Runtime type guard for {@link TWidgetValue}. Accepts any value shape that a
|
||||
* widget can legally hold: primitives (`string`, `number`, `boolean`),
|
||||
* non-null objects (arrays, plain objects), or `undefined`. Rejects `null` and
|
||||
* functions. Used at serialization / migration boundaries that consume
|
||||
* `unknown` payloads (e.g. `widgets_values`).
|
||||
*/
|
||||
export function isWidgetValue(value: unknown): value is TWidgetValue {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* The base type for all widgets. Should not be implemented directly.
|
||||
* @template TValue The type of value this widget holds.
|
||||
|
||||
@@ -267,9 +267,14 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
|
||||
const targetEntityId = widget.entityId
|
||||
if (!targetEntityId) return
|
||||
const entry = selectedInputs.value.find(([id]) => id === targetEntityId)
|
||||
if (!entry) return
|
||||
entry[2] = { ...entry[2], ...config }
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id]) => id === targetEntityId
|
||||
)
|
||||
if (index === -1) return
|
||||
// Replace the tuple rather than mutating its `[2]` slot in place so
|
||||
// reactive consumers that key off entry identity see the change.
|
||||
const [id, type, options] = selectedInputs.value[index]
|
||||
selectedInputs.value.splice(index, 1, [id, type, { ...options, ...config }])
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user