Compare commits

...

4 Commits

Author SHA1 Message Date
Austin Mroz
65900a8069 Use unique symbold for branding. 2025-09-23 18:42:37 -05:00
Austin Mroz
5ea7a56b64 Stricter typing for nodeids 2025-09-23 16:45:46 -05:00
Austin Mroz
109173fad1 Make instanceof bifurcate type of LinkSegment.id
LinkSegment.id can be either a RerouteId or a LinkId. but guarding the
type of instance as "not Reroute" or "not LLink" was not sufficient to
narrow the type of id. It is split into an interface, and an instance
type to ensure that any future changes create type errors instead of
runtime errors.

This also reverts the change to the createReroute code. The change was
incorrect.
2025-09-23 11:53:31 -05:00
Austin Mroz
f1cb8edcaa Make Id types mutually incompatible 2025-09-23 09:34:40 -05:00
16 changed files with 83 additions and 61 deletions

View File

@@ -3,6 +3,8 @@
*/
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
export class VueNodeHelpers {
constructor(private page: Page) {}
@@ -39,7 +41,7 @@ export class VueNodeHelpers {
/**
* Get all Vue node IDs currently in the DOM
*/
async getNodeIds(): Promise<string[]> {
async getNodeIds(): Promise<NodeId[]> {
return await this.nodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
@@ -50,14 +52,14 @@ export class VueNodeHelpers {
/**
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
async selectNode(nodeId: nodeId): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
* Select multiple Vue nodes by IDs using Ctrl+click
*/
async selectNodes(nodeIds: string[]): Promise<void> {
async selectNodes(nodeIds: NodeId[]): Promise<void> {
if (nodeIds.length === 0) return
// Select first node normally

View File

@@ -1,6 +1,8 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface ChatHistoryEntry {
@@ -33,7 +35,7 @@ async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
}
test.describe('Chat History Widget', () => {
let nodeId: string
let nodeId: NodeId
test.beforeEach(async ({ comfyPage }) => {
nodeId = await renderChatHistory(comfyPage.page, [

View File

@@ -574,7 +574,7 @@ export class LGraph
const S: LGraphNode[] = []
const M: Dictionary<LGraphNode> = {}
// to avoid repeating links
const visited_links: Record<NodeId, boolean> = {}
const visited_links: Record<LinkId, boolean> = {}
const remaining_links: Record<NodeId, number> = {}
// search for the nodes without inputs (starting nodes)

View File

@@ -6168,14 +6168,14 @@ export class LGraphCanvas
case 'Delete': {
// segment can be a Reroute object, in which case segment.id is the reroute id
const linkId =
segment instanceof Reroute
? segment.linkIds.values().next().value
: segment.id
if (linkId !== undefined) {
graph.removeLink(linkId)
// Clean up layout store
layoutStore.deleteLinkLayout(linkId)
const linkIds =
segment instanceof Reroute ? segment.linkIds : [segment.id]
for (const linkId of linkIds) {
if (linkId !== undefined) {
graph.removeLink(linkId)
// Clean up layout store
layoutStore.deleteLinkLayout(linkId)
}
}
break
}

View File

@@ -40,7 +40,8 @@ import type {
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size
Size,
UniqueId
} from './interfaces'
import {
type LGraphNodeConstructor,
@@ -90,7 +91,7 @@ import { type WidgetTypeMap, toConcreteWidget } from './widgets/widgetMap'
// #region Types
export type NodeId = number | string
export type NodeId = UniqueId<number | string, 'NodeId'>
export type NodeProperty = string | number | boolean | object

View File

@@ -9,12 +9,14 @@ import type { LGraphNode, NodeId } from './LGraphNode'
import type { Reroute, RerouteId } from './Reroute'
import type {
CanvasColour,
ILinkSegment,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LinkNetwork,
LinkSegment,
ReadonlyLinkNetwork
ReadonlyLinkNetwork,
UniqueId
} from './interfaces'
import type {
Serialisable,
@@ -24,7 +26,7 @@ import type {
const layoutMutations = useLayoutMutations()
export type LinkId = number
export type LinkId = UniqueId<number, 'LinkId'>
export type SerialisedLLinkArray = [
id: LinkId,
@@ -90,7 +92,7 @@ type BasicReadonlyNetwork = Pick<
>
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
export class LLink implements ILinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
/** Link ID */

View File

@@ -6,21 +6,22 @@ import type { LGraphNode, NodeId } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
import type {
CanvasColour,
ILinkSegment,
INodeInputSlot,
INodeOutputSlot,
LinkNetwork,
LinkSegment,
Point,
Positionable,
ReadOnlyRect,
ReadonlyLinkNetwork
ReadonlyLinkNetwork,
UniqueId
} from './interfaces'
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type RerouteId = number
export type RerouteId = UniqueId<number, 'RerouteId'>
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
@@ -36,7 +37,7 @@ export interface FloatingRerouteSlot {
* and a `WeakRef` to a {@link LinkNetwork} to resolve them.
*/
export class Reroute
implements Positionable, LinkSegment, Serialisable<SerialisableReroute>
implements Positionable, ILinkSegment, Serialisable<SerialisableReroute>
{
static radius: number = 10
/** Maximum distance from reroutes to their bezier curve control points. */

View File

@@ -11,6 +11,9 @@ import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type { LinkDirection, RenderShape } from './types/globalEnums'
import type { IBaseWidget } from './types/widgets'
declare const __brand: unique symbol
export type UniqueId<T, B> = T & { [__brand]?: B }
export type Dictionary<T> = { [key: string]: T }
/** Allows all properties to be null. The same as `Partial<T>`, but adds null instead of undefined. */
@@ -183,8 +186,10 @@ export interface ItemLocator {
): SubgraphInputNode | SubgraphOutputNode | undefined
}
export type LinkSegment = Reroute | LLink
/** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */
export interface LinkSegment {
export interface ILinkSegment {
/** Link / reroute ID */
readonly id: LinkId | RerouteId
/** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */

View File

@@ -182,14 +182,14 @@ interface WorkflowStore {
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
executionIdToCurrentId: (id: NodeExecutionId) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
nodeExecutionId: NodeExecutionId
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
locatorId: NodeLocatorId,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
@@ -518,14 +518,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive.value = isSubgraph(subgraph)
}
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const subgraphNodeIdToSubgraph = (id: NodeId, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphNodeIds: NodeId[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
const currentPart = subgraphNodeIds.shift()
@@ -538,7 +538,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const executionIdToCurrentId = (id: NodeExecutionId) => {
const subgraph = activeSubgraph.value
// Short-circuit: ID belongs to the parent workflow / no active subgraph
@@ -588,11 +588,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
nodeExecutionId: NodeExecutionId
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
return nodeExecutionId as NodeLocatorId
}
const parts = parseNodeExecutionId(nodeExecutionId)
@@ -623,9 +623,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const nodeLocatorIdToNodeId = (locatorId: NodeLocatorId): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
@@ -637,7 +635,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
locatorId: NodeLocatorId,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)

View File

@@ -151,8 +151,9 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(
this.id
)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
@@ -376,7 +377,7 @@ export const useLitegraphService = () => {
node.title = nodeDef.display_name || nodeDef.name
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
async function registerNodeDef(type: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
static comfyClass: string
static override title: string
@@ -416,8 +417,9 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(
this.id
)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
@@ -649,7 +651,7 @@ export const useLitegraphService = () => {
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
node.nodeData = nodeDef
LiteGraph.registerNodeType(nodeId, node)
LiteGraph.registerNodeType(type, node)
// Note: Do not following assignments before `LiteGraph.registerNodeType`
// because `registerNodeType` will overwrite the assignments.
node.category = nodeDef.category

View File

@@ -28,7 +28,7 @@ import type {
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface QueuedPrompt {
@@ -78,8 +78,10 @@ function getSubgraphsFromInstanceIds(
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
const nodeIdStr = String(nodeId)
function executionIdToNodeLocatorId(
executionId: NodeExecutionId
): NodeLocatorId {
const nodeIdStr = String(executionId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID

View File

@@ -1,3 +1,4 @@
import type { UniqueId } from '@/lib/litegraph/src/interfaces'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
@@ -15,7 +16,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
* Unlike execution IDs which change based on the instance path,
* NodeLocatorId remains the same for all instances of a particular node.
*/
export type NodeLocatorId = string
export type NodeLocatorId = UniqueId<string, 'NodeLocatorId'>
/**
* An execution identifier representing a node's position in nested subgraphs.
@@ -24,7 +25,8 @@ export type NodeLocatorId = string
* Format: Colon-separated path of node IDs
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
*/
export type NodeExecutionId = string
declare const __executionIdBrand: unique symbol
export type NodeExecutionId = UniqueId<string, 'NodeExecutionId'>
/**
* Type guard to check if a value is a NodeLocatorId
@@ -105,7 +107,7 @@ export function createNodeLocatorId(
* @param id The NodeExecutionId to parse
* @returns Array of node IDs from root to target, or null if not an execution ID
*/
export function parseNodeExecutionId(id: string): NodeId[] | null {
export function parseNodeExecutionId(id: NodeExecutionId): NodeId[] | null {
if (!isNodeExecutionId(id)) return null
return id

View File

@@ -1,3 +1,4 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphNode,
@@ -9,7 +10,7 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
interface NodeWithId {
id: string | number
id: NodeId
subgraphId?: string | null
}
@@ -19,7 +20,7 @@ interface NodeWithId {
* @param nodeData - Node data containing id and optional subgraphId
* @returns The locator ID string
*/
export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
export function getLocatorIdFromNodeData(nodeData: NodeWithId): NodeLocatorId {
return nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
@@ -31,7 +32,9 @@ export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of node IDs in the path, or null if invalid
*/
export function parseExecutionId(executionId: string): string[] | null {
export function parseExecutionId(
executionId: NodeExecutionId
): string[] | null {
if (!executionId || typeof executionId !== 'string') return null
return executionId.split(':').filter((part) => part.length > 0)
}
@@ -55,7 +58,9 @@ export function getLocalNodeIdFromExecutionId(
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of subgraph node IDs (excluding the final node ID), or empty array
*/
export function getSubgraphPathFromExecutionId(executionId: string): string[] {
export function getSubgraphPathFromExecutionId(
executionId: NodeExecutionId
): string[] {
const parts = parseExecutionId(executionId)
return parts ? parts.slice(0, -1) : []
}
@@ -196,7 +201,7 @@ export function collectAllNodes(
*/
export function findNodeInHierarchy(
graph: LGraph | Subgraph,
nodeId: string | number
nodeId: NodeId
): LGraphNode | null {
// Check current graph
const node = graph.getNodeById(nodeId)
@@ -284,7 +289,7 @@ export function findSubgraphPathById(
*/
export function getNodeByExecutionId(
rootGraph: LGraph,
executionId: string
executionId: NodeExecutionId
): LGraphNode | null {
if (!rootGraph) return null
@@ -316,7 +321,7 @@ export function getNodeByExecutionId(
*/
export function getNodeByLocatorId(
rootGraph: LGraph,
locatorId: NodeLocatorId | string
locatorId: NodeLocatorId
): LGraphNode | null {
if (!rootGraph) return null

View File

@@ -25,7 +25,7 @@
* SOFTWARE.
*/
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import type { LinkId, SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import type {
ISerialisedGraph,
@@ -105,7 +105,7 @@ export function fixBadLinks(
const data: {
patchedNodes: Array<ISerialisedNode | LGraphNode>
deletedLinks: number[]
deletedLinks: LinkId[]
} = {
patchedNodes: [],
deletedLinks: []
@@ -419,7 +419,7 @@ export function fixBadLinks(
for (let i = data.deletedLinks.length - 1; i >= 0; i--) {
logger.log(`Deleting link #${data.deletedLinks[i]}.`)
if ((graph as LGraph).getNodeById) {
delete graph.links[data.deletedLinks[i]!]
delete (graph as LGraph).links[data.deletedLinks[i]!]
} else {
graph = graph as ISerialisedGraph
// Sometimes we got objects for links if passed after ComfyUI's loadGraphData modifies the

View File

@@ -16,7 +16,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
// Remove any previous global types
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window {}
}
@@ -148,7 +147,7 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
})
it('should handle numeric node IDs', () => {
const result = store.executionIdToNodeLocatorId(123)
const result = store.executionIdToNodeLocatorId('123')
// For numeric IDs, it should convert to string and return as-is
expect(result).toBe('123')

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphNode,
@@ -61,7 +62,7 @@ function createMockSubgraph(id: string, nodes: LGraphNode[]): Subgraph {
id,
_nodes: nodes,
nodes: nodes,
getNodeById: (nodeId: string | number) =>
getNodeById: (nodeId: NodeId) =>
nodes.find((n) => String(n.id) === String(nodeId)) || null
} as unknown as Subgraph
}