Files
ComfyUI_frontend/docs/adr/0006-primitive-node-copy-paste-lifecycle.md
Christian Byrne db04381784 fix: wrap configure() widget restoration in hydration transaction (#10201)
## Summary

Wraps `LGraphNode.configure()`'s widget-value restoration phase in a
hydration transaction (`beginHydration` / `commitHydration`), preventing
derived-state callbacks (e.g. CustomCombo's `updateCombo`) from firing
before all widget values are restored.

### Architecture

```mermaid
flowchart LR
    subgraph core["Core Layer"]
        LGN["LGraphNode.configure()"]
        WVS["widgetValueStore"]
    end

    subgraph extensions["Extension Layer"]
        CW["CustomCombo\n(customWidgets.ts)"]
        PN["PrimitiveNode\n(widgetInputs.ts)"]
    end

    LGN -- "beginHydration /\ncommitHydration" --> WVS
    CW -- "isHydrating() guard" --> WVS
    CW -- "onHydrationComplete()" --> WVS
    PN -- "serialize() override\npreserves widgets_values" --> LGN
```

### Hydration Sequence

```mermaid
sequenceDiagram
    participant Caller as Paste / Load
    participant Node as LGraphNode.configure()
    participant Store as widgetValueStore
    participant Widget as Widget Setters
    participant Combo as CustomCombo.updateCombo

    Caller->>Node: configure(serializedData)
    Node->>Store: beginHydration(nodeId)
    activate Store
    Note over Store: hydratingNodes.add(nodeId)

    loop For each widget
        Node->>Widget: widget.value = restored_value
        Widget->>Combo: updateCombo() triggered
        Combo->>Store: isHydrating(nodeId)?
        Store-->>Combo: true - early return
        Note over Combo: Side effect suppressed
    end

    Node->>Node: onConfigure(info)
    Note over Node: Extensions register onHydrationComplete callbacks

    Node->>Store: commitHydration(nodeId)
    Note over Store: hydratingNodes.delete(nodeId)
    Store->>Combo: fire queued callbacks
    Note over Combo: updateCombo() runs once with all values present
    deactivate Store
```

### Before / After

```mermaid
flowchart TB
    subgraph before["Before: Race Condition"]
        direction TB
        B1["configure starts"] --> B2["widget1.value = optionA"]
        B2 --> B3["updateCombo fires immediately"]
        B3 --> B4["values list incomplete,\ncomboWidget.value reset"]
        B4 --> B5["widget2.value = optionB"]
        B5 --> B6["updateCombo fires again"]
        B6 --> B7["Combo has wrong value"]
    end

    subgraph after["After: Hydration Transaction"]
        direction TB
        A1["configure starts"] --> A2["beginHydration nodeId"]
        A2 --> A3["widget1.value = optionA"]
        A3 --> A4["updateCombo skipped"]
        A4 --> A5["widget2.value = optionB"]
        A5 --> A6["updateCombo skipped"]
        A6 --> A7["commitHydration nodeId"]
        A7 --> A8["updateCombo runs once,\nall values present"]
    end
```

### Changes

- **`LGraphNode.configure()`**: Wraps widget restoration + `onConfigure`
in `beginHydration`/`commitHydration` with `try/finally` for exception
safety
- **`PrimitiveNode.serialize()`**: Adds override to preserve
`widgets_values` when widgets are dynamically disconnected
- **`customWidgets.ts`**: Simplifies CustomCombo's `onConfigure` to use
`onHydrationComplete()` instead of manually managing hydration state
- **3 new tests**: Hydration transaction wrapping, `onHydrationComplete`
callback firing, and exception safety

### Part of

Stacked on #10010. Implements Design A from the widget state
architecture RFC.
2026-03-25 21:16:32 -07:00

2.9 KiB

6. PrimitiveNode Copy/Paste Lifecycle

Date: 2026-02-22

Status

Accepted (Option A)

Context

PrimitiveNode creates widgets dynamically on connection. When copied, the clone has no this.widgets, so LGraphNode.serialize() drops widgets_values from the clipboard data. This causes secondary widget values (e.g., control_after_generate) to be lost on paste. See WIDGET_SERIALIZATION.md for the full mechanism.

Related: #1757, #8938

Options

A. Minimal fix: override serialize() on PrimitiveNode

Override serialize() to fall back to this.widgets_values (set during configure()) when the base implementation omits it due to missing this.widgets.

  • Pro: No change to connection lifecycle semantics. Lowest risk.
  • Pro: Doesn't affect workflow save/load (which already works via onAfterGraphConfigured).
  • Con: Doesn't address the deeper design issue — primitives are still empty on copy.

B. Clone-configured-instance lifecycle

On copy, the primitive is a clone of the configured instance (with widgets intact). On disconnect or paste without connections, it returns to empty state.

  • Pro: Copy→serialize captures widgets_values correctly. Matches OOP expectations.
  • Pro: Secondary widget state survives round-trips without special-casing.
  • Con: input.widget[CONFIG] allows extensions to make PrimitiveNode create a different widget than the target. Widget config is derived at connection time, not stored, so cloning the configured state may not be faithful.
  • Con: Deserialization ordering — configure() runs before links are restored. PrimitiveNode needs links to know what widgets to create. onAfterGraphConfigured() handles this for workflow load, but copy/paste uses a different code path.
  • Con: Higher risk of regressions in extension compatibility.

C. Projection model (like Subgraph widgets)

Primitives act as a synchronization mechanism — no own state, just a projection of the target widget's resolved value.

  • Pro: Cleanest conceptual model. Eliminates state duplication.
  • Con: Primitives can connect to multiple targets. Projection with multiple targets is ambiguous.
  • Con: Major architectural change with broad impact.

Decision

Option A. Override serialize() on PrimitiveNode to preserve widgets_values through copy-paste. This is the lowest-risk fix with no change to connection lifecycle semantics.

Prerequisite: PR #10010 replaced clone().serialize() with direct serialization in _serializeItems, eliminating the code path that dropped widgets_values for widget-less clones. Option A provides the PrimitiveNode-specific fallback for any remaining edge cases.

Option B can be revisited after Option A ships and stabilizes.