Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
7cabe98c20 fix: remove redundant spread in renderDraggingLink call
renderDraggingLink already applies linkMarkerShape: None internally,
so the spread + override at the call site was unnecessary allocation.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9405#discussion_r2887883450
2026-03-15 22:16:02 -07:00
bymyself
664ee8fcfc fix: simplify slot iteration with for-of loop
Replace indexed slotArrays loop with idiomatic for-of over the two
concrete slot arrays.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/9405#discussion_r2887926847
2026-03-15 22:15:58 -07:00
bymyself
8f7f4bcc19 fix: reset link render context cache at frame start
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb8f-0894-7504-87c1-057e8818587e
2026-03-04 18:50:27 -08:00
GitHub Action
b0fd4fe4c1 [automated] Apply ESLint and Oxfmt fixes 2026-03-05 01:04:50 +00:00
bymyself
b14d083c5f fix: reduce per-frame allocations in canvas draw loop
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb69-3404-726a-8888-182193115b88
2026-03-04 17:02:14 -08:00
5 changed files with 164 additions and 50 deletions

View File

@@ -667,6 +667,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* performant than {@link visible_nodes} for visibility checks.
*/
private _visible_node_ids: Set<NodeId> = new Set()
/** Cached per-frame link render context to avoid rebuilding per-link. */
private _cachedLinkRenderContext: LinkRenderContext | null = null
node_over?: LGraphNode
node_capturing_input?: LGraphNode | null
highlighted_links: Dictionary<boolean> = {}
@@ -4806,6 +4809,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0)
return
// Reset per-frame caches so stale data is never used if a code path is skipped.
this._cachedLinkRenderContext = null
// fps counting
const now = LiteGraph.getTime()
this.render_time = (now - this.last_draw_time) * 0.001
@@ -4816,10 +4822,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Compute node size before drawing links.
if (this.dirty_canvas || force_canvas) {
this.computeVisibleNodes(undefined, this.visible_nodes)
// Update visible node IDs
this._visible_node_ids = new Set(
this.visible_nodes.map((node) => node.id)
)
// Update visible node IDs (reuse existing Set to avoid allocation)
this._visible_node_ids.clear()
for (const node of this.visible_nodes) {
this._visible_node_ids.add(node.id)
}
// Arrange subgraph IO nodes
const { subgraph } = this
@@ -5014,10 +5021,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
colour,
fromDirection,
dragDirection,
{
...this.buildLinkRenderContext(),
linkMarkerShape: LinkMarkerShape.None
}
this._cachedLinkRenderContext ?? this.buildLinkRenderContext()
)
}
if (renderLink instanceof MovingInputLink) this.setDirty(false, true)
@@ -5765,6 +5769,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.renderedPaths.clear()
if (this.links_render_mode === LinkRenderType.HIDDEN_LINK) return
// Cache the link render context once per frame
this._cachedLinkRenderContext = this.buildLinkRenderContext()
// Skip link rendering while waiting for slot positions to sync after reconfigure
if (LiteGraph.vueNodesMode && layoutStore.pendingSlotSync) {
this._visibleReroutes.clear()
@@ -6016,19 +6023,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Get all points this link passes through
const reroutes = LLink.getReroutes(graph, link)
const points: [Point, ...Point[], Point] = [
startPos,
...reroutes.map((x) => x.pos),
endPos
]
// Bounding box of all points (bezier overshoot on long links will be cut)
const pointsX = points.map((x) => x[0])
const pointsY = points.map((x) => x[1])
link_bounding[0] = Math.min(...pointsX)
link_bounding[1] = Math.min(...pointsY)
link_bounding[2] = Math.max(...pointsX) - link_bounding[0]
link_bounding[3] = Math.max(...pointsY) - link_bounding[1]
// Compute bounding box inline to avoid allocating temporary arrays
let minX = startPos[0]
let minY = startPos[1]
let maxX = minX
let maxY = minY
for (let i = 0; i < reroutes.length; i++) {
const pos = reroutes[i].pos
if (pos[0] < minX) minX = pos[0]
else if (pos[0] > maxX) maxX = pos[0]
if (pos[1] < minY) minY = pos[1]
else if (pos[1] > maxY) maxY = pos[1]
}
if (endPos[0] < minX) minX = endPos[0]
else if (endPos[0] > maxX) maxX = endPos[0]
if (endPos[1] < minY) minY = endPos[1]
else if (endPos[1] > maxY) maxY = endPos[1]
link_bounding[0] = minX
link_bounding[1] = minY
link_bounding[2] = maxX - minX
link_bounding[3] = maxY - minY
// skip links outside of the visible area of the canvas
if (!overlapBounding(link_bounding, margin_area)) return
@@ -6096,8 +6112,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Skip the last segment if it is being dragged
if (link._dragging) return
// Use runtime fallback; TypeScript cannot evaluate this correctly.
const segmentStartPos = points.at(-2) ?? startPos
const segmentStartPos = reroutes.at(-1)?.pos ?? startPos
// Render final link segment
this.renderLink(
@@ -6218,7 +6233,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} = {}
): void {
if (this.linkRenderer) {
const context = this.buildLinkRenderContext()
const context =
this._cachedLinkRenderContext ?? this.buildLinkRenderContext()
this.linkRenderer.renderLinkDirect(
ctx,
a,

View File

@@ -653,4 +653,70 @@ describe('LGraphNode', () => {
)
})
})
describe('_slotsDirty flag', () => {
test('starts dirty', () => {
const n = new LGraphNode('Test')
expect(n._slotsDirty).toBe(true)
})
test('is cleared by _setConcreteSlots', () => {
const n = new LGraphNode('Test')
n._setConcreteSlots()
expect(n._slotsDirty).toBe(false)
})
test('skips work when clean', () => {
const n = new LGraphNode('Test')
n.addInput('in', 'number')
n._setConcreteSlots()
expect(n._slotsDirty).toBe(false)
const mapSpy = vi.spyOn(n.inputs, 'map')
n._setConcreteSlots()
expect(mapSpy).not.toHaveBeenCalled()
})
test('is set by addInput', () => {
const n = new LGraphNode('Test')
n._setConcreteSlots()
n.addInput('in', 'number')
expect(n._slotsDirty).toBe(true)
})
test('is set by removeInput', () => {
const n = new LGraphNode('Test')
n.addInput('in', 'number')
n._setConcreteSlots()
n.removeInput(0)
expect(n._slotsDirty).toBe(true)
})
test('is set by addOutput', () => {
const n = new LGraphNode('Test')
n._setConcreteSlots()
n.addOutput('out', 'number')
expect(n._slotsDirty).toBe(true)
})
test('is set by removeOutput', () => {
const n = new LGraphNode('Test')
n.addOutput('out', 'number')
n._setConcreteSlots()
n.removeOutput(0)
expect(n._slotsDirty).toBe(true)
})
test('is set by configure', () => {
const n = new LGraphNode('Test')
n._setConcreteSlots()
n.configure(
getMockISerialisedNode({
inputs: [{ name: 'a', type: 'number', link: null }],
outputs: [{ name: 'b', type: 'number', links: null }]
})
)
expect(n._slotsDirty).toBe(true)
})
})
})

View File

@@ -279,6 +279,8 @@ export class LGraphNode
private _concreteInputs: NodeInputSlot[] = []
private _concreteOutputs: NodeOutputSlot[] = []
/** @internal Set when inputs/outputs change; cleared by {@link _setConcreteSlots}. */
_slotsDirty: boolean = true
properties: Dictionary<NodeProperty | undefined> = {}
properties_info: INodePropertyInfo[] = []
@@ -864,6 +866,7 @@ export class LGraphNode
this.inputs = this.inputs.map((input) =>
toClass(NodeInputSlot, input, this)
)
this._slotsDirty = true
for (const [i, input] of this.inputs.entries()) {
const link =
this.graph && input.link != null
@@ -1630,6 +1633,7 @@ export class LGraphNode
this.outputs ||= []
this.outputs.push(output)
this._slotsDirty = true
this.onOutputAdded?.(output)
if (LiteGraph.auto_load_slot_types)
@@ -1650,6 +1654,7 @@ export class LGraphNode
}
const { outputs } = this
outputs.splice(slot, 1)
this._slotsDirty = true
for (let i = slot; i < outputs.length; ++i) {
const output = outputs[i]
@@ -1687,6 +1692,7 @@ export class LGraphNode
this.inputs ||= []
this.inputs.push(input)
this._slotsDirty = true
this.expandToFitContent()
this.onInputAdded?.(input)
@@ -1706,6 +1712,7 @@ export class LGraphNode
}
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
this._slotsDirty = true
for (let i = slot; i < inputs.length; ++i) {
const input = inputs[i]
@@ -4080,33 +4087,36 @@ export class LGraphNode
ctx: CanvasRenderingContext2D,
{ fromSlot, colorContext, editorAlpha, lowQuality }: DrawSlotsOptions
) {
for (const slot of [...this._concreteInputs, ...this._concreteOutputs]) {
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
const isMouseOverSlot = this._isMouseOverSlot(slot)
for (const slots of [this._concreteInputs, this._concreteOutputs]) {
for (let s = 0; s < slots.length; s++) {
const slot = slots[s]
const isValidTarget = fromSlot && slot.isValidTarget(fromSlot)
const isMouseOverSlot = this._isMouseOverSlot(slot)
// change opacity of incompatible slots when dragging a connection
const isValid = !fromSlot || isValidTarget
const highlight = isValid && isMouseOverSlot
// change opacity of incompatible slots when dragging a connection
const isValid = !fromSlot || isValidTarget
const highlight = isValid && isMouseOverSlot
// Show slot if it's not a widget input slot
// or if it's a widget input slot and satisfies one of the following:
// - the mouse is over the widget
// - the slot is valid during link drop
// - the slot is connected
if (
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slot.draw(ctx, {
colorContext,
lowQuality,
highlight
})
// Show slot if it's not a widget input slot
// or if it's a widget input slot and satisfies one of the following:
// - the mouse is over the widget
// - the slot is valid during link drop
// - the slot is connected
if (
isMouseOverSlot ||
isValidTarget ||
!slot.isWidgetInputSlot ||
this._isMouseOverWidget(this.getWidgetFromSlot(slot)) ||
slot.isConnected ||
slot.alwaysVisible
) {
ctx.globalAlpha = isValid ? editorAlpha : 0.4 * editorAlpha
slot.draw(ctx, {
colorContext,
lowQuality,
highlight
})
}
}
}
}
@@ -4247,12 +4257,15 @@ export class LGraphNode
* have been removed from the ecosystem.
*/
_setConcreteSlots(): void {
if (!this._slotsDirty) return
this._concreteInputs = this.inputs.map((slot) =>
toClass(NodeInputSlot, slot, this)
)
this._concreteOutputs = this.outputs.map((slot) =>
toClass(NodeOutputSlot, slot, this)
)
this._slotsDirty = false
}
/**

View File

@@ -14,4 +14,18 @@ describe('LLink', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('getReroutes', () => {
test('returns the same empty array instance for links without reroutes', () => {
const network = { reroutes: new Map() }
const link1 = new LLink(1, 'float', 4, 2, 5, 3)
const link2 = new LLink(2, 'float', 4, 2, 5, 3)
const result1 = LLink.getReroutes(network, link1)
const result2 = LLink.getReroutes(network, link2)
expect(result1).toHaveLength(0)
expect(result1).toBe(result2)
})
})
})

View File

@@ -23,6 +23,8 @@ import type { Serialisable, SerialisableLLink } from './types/serialisation'
const layoutMutations = useLayoutMutations()
const EMPTY_REROUTES: Reroute[] = [] as Reroute[]
export type LinkId = number
export type SerialisedLLinkArray = [
@@ -204,8 +206,11 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
linkSegment: LinkSegment
): Reroute[] {
if (linkSegment.parentId === undefined) return []
return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? []
if (linkSegment.parentId === undefined) return EMPTY_REROUTES
return (
network.reroutes.get(linkSegment.parentId)?.getReroutes() ??
EMPTY_REROUTES
)
}
static getFirstReroute(