Skip to content

Commit 0a8f16f

Browse files
[feat] Add containerNode property for DOM widget positioning in subgraphs (#1128)
Co-authored-by: Claude <[email protected]>
1 parent 2fd9431 commit 0a8f16f

File tree

7 files changed

+380
-5
lines changed

7 files changed

+380
-5
lines changed

CLAUDE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,39 @@
2424

2525
- Be sure to typecheck when you’re done making a series of code changes
2626
- Prefer running single tests, and not the whole test suite, for performance
27+
28+
# Testing Guidelines
29+
30+
## Avoiding Circular Dependencies in Tests
31+
32+
**CRITICAL**: When writing tests for subgraph-related code, always import from the barrel export to avoid circular dependency issues:
33+
34+
```typescript
35+
// ✅ CORRECT - Use barrel import
36+
import { LGraph, Subgraph, SubgraphNode } from "@/litegraph"
37+
38+
// ❌ WRONG - Direct imports cause circular dependency
39+
import { LGraph } from "@/LGraph"
40+
import { Subgraph } from "@/subgraph/Subgraph"
41+
import { SubgraphNode } from "@/subgraph/SubgraphNode"
42+
```
43+
44+
**Root cause**: `LGraph` and `Subgraph` have a circular dependency:
45+
- `LGraph.ts` imports `Subgraph` (creates instances with `new Subgraph()`)
46+
- `Subgraph.ts` extends `LGraph`
47+
48+
The barrel export (`@/litegraph`) handles this properly, but direct imports cause module loading failures.
49+
50+
## Test Setup for Subgraphs
51+
52+
Use the provided test helpers for consistent setup:
53+
54+
```typescript
55+
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
56+
57+
function createTestSetup() {
58+
const subgraph = createTestSubgraph()
59+
const subgraphNode = createTestSubgraphNode(subgraph)
60+
return { subgraph, subgraphNode }
61+
}
62+
```

src/infrastructure/SubgraphEventMap.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { LGraphEventMap } from "./LGraphEventMap"
22
import type { SubgraphInput } from "@/subgraph/SubgraphInput"
3+
import type { SubgraphNode } from "@/subgraph/SubgraphNode"
34
import type { SubgraphOutput } from "@/subgraph/SubgraphOutput"
5+
import type { IBaseWidget } from "@/types/widgets"
46

57
export interface SubgraphEventMap extends LGraphEventMap {
68
"adding-input": {
@@ -40,4 +42,13 @@ export interface SubgraphEventMap extends LGraphEventMap {
4042
oldName: string
4143
newName: string
4244
}
45+
46+
"widget-promoted": {
47+
widget: IBaseWidget
48+
subgraphNode: SubgraphNode
49+
}
50+
"widget-unpromoted": {
51+
widget: IBaseWidget
52+
subgraphNode: SubgraphNode
53+
}
4354
}

src/subgraph/SubgraphNode.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
187187
}
188188

189189
#setWidget(subgraphInput: Readonly<SubgraphInput>, input: INodeInputSlot, widget: Readonly<IBaseWidget>) {
190-
// Use the first matching widget
191-
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(this)
190+
const concreteWidget = toConcreteWidget(widget, this)
191+
192+
const promotedWidget = concreteWidget.createCopyForNode(this)
193+
194+
// Set parentSubgraphNode for all promoted widgets to track their origin
195+
promotedWidget.parentSubgraphNode = this
196+
192197
Object.assign(promotedWidget, {
193198
get name() {
194199
return subgraphInput.name
@@ -212,6 +217,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
212217

213218
this.widgets.push(promotedWidget)
214219

220+
// Dispatch widget-promoted event
221+
this.subgraph.events.dispatch("widget-promoted", { widget: promotedWidget, subgraphNode: this })
222+
215223
input.widget = { name: subgraphInput.name }
216224
input._widget = promotedWidget
217225
}
@@ -307,7 +315,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
307315
return nodes
308316
}
309317

318+
override removeWidgetByName(name: string): void {
319+
const widget = this.widgets.find(w => w.name === name)
320+
if (widget) {
321+
this.subgraph.events.dispatch("widget-unpromoted", { widget, subgraphNode: this })
322+
}
323+
super.removeWidgetByName(name)
324+
}
325+
326+
override ensureWidgetRemoved(widget: IBaseWidget): void {
327+
if (this.widgets.includes(widget)) {
328+
this.subgraph.events.dispatch("widget-unpromoted", { widget, subgraphNode: this })
329+
}
330+
super.ensureWidgetRemoved(widget)
331+
}
332+
310333
override onRemoved(): void {
334+
// Clean up all promoted widgets
335+
for (const widget of this.widgets) {
336+
widget.parentSubgraphNode = undefined
337+
this.subgraph.events.dispatch("widget-unpromoted", { widget, subgraphNode: this })
338+
}
339+
311340
for (const input of this.inputs) {
312341
input._listenerController?.abort()
313342
}

src/types/widgets.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ export interface IBaseWidget<
201201

202202
tooltip?: string
203203

204+
/**
205+
* Reference to the subgraph container node when this widget is promoted from a subgraph.
206+
* This allows the widget to know which SubgraphNode it belongs to in the parent graph.
207+
*/
208+
parentSubgraphNode?: LGraphNode
209+
204210
// TODO: Confirm this format
205211
callback?(
206212
value: any,

src/widgets/BaseWidget.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget> impl
5656
return this.#node
5757
}
5858

59+
/**
60+
* Reference to the subgraph container node when this widget is promoted from a subgraph.
61+
* This allows the widget to know which SubgraphNode it belongs to in the parent graph.
62+
*/
63+
parentSubgraphNode?: LGraphNode
64+
5965
linkedWidgets?: IBaseWidget[]
6066
name: string
6167
options: TWidget["options"]
@@ -297,4 +303,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget> impl
297303
cloned.value = this.value
298304
return cloned
299305
}
306+
307+
/**
308+
* Type guard to check if this widget has a DOM element.
309+
* @returns True if the widget has a DOM element attached
310+
*/
311+
isDOMWidget(): this is this & { element: HTMLElement } {
312+
return this.element instanceof HTMLElement
313+
}
300314
}

test/subgraph/SubgraphEdgeCases.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ describe("SubgraphEdgeCases - Recursion Detection", () => {
6767
it("should respect MAX_NESTED_SUBGRAPHS constant", () => {
6868
// Verify the constant exists and is a reasonable positive number
6969
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeDefined()
70-
expect(typeof Subgraph.MAX_NESTED_SUBGRAPHS).toBe('number')
70+
expect(typeof Subgraph.MAX_NESTED_SUBGRAPHS).toBe("number")
7171
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeGreaterThan(0)
72-
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeLessThanOrEqual(10000) // Reasonable upper bound
72+
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBeLessThanOrEqual(10_000) // Reasonable upper bound
7373

7474
// Note: Currently not enforced in implementation
7575
// This test documents the intended behavior
@@ -313,7 +313,7 @@ describe("SubgraphEdgeCases - Performance and Scale", () => {
313313
const flattened = subgraphNode.getInnerNodes(executableNodes)
314314

315315
expect(flattened).toHaveLength(50)
316-
316+
317317
// Performance is acceptable for 50 nodes (typically < 1ms)
318318
})
319319

0 commit comments

Comments
 (0)