Audit triggered while debugging the v0.2.0 release surfaced drift in the maintainer docs vs current code: dead curated_defaults.json references (file replaced by starter_preset.json in PR #72), the v1_0_1 fork example (removed in v0.1.5), and a "tooling we don't have yet" entry that's now covered by tools/preflight_release.py. Adds a new conventions.md for cross-cutting rules — currently the Mermaid-for-diagrams convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.8 KiB
Node versioning — how to change a node without breaking saved workflows
This is a hard-won list. ComfyUI's workflow format silently breaks in
several non-obvious ways when you change a node's inputs, outputs, or
class name. The rules below are what to follow whenever you touch
INPUT_TYPES, RETURN_TYPES, or NODE_CLASS_MAPPINGS.
How ComfyUI loads a saved workflow
When the frontend opens a workflow JSON, it reconstructs each node by:
- Looking up the node by its registered ID (the key in
NODE_CLASS_MAPPINGS). If the lookup fails, the node renders as a red "missing" stub and the workflow won't run. - Mapping connections by input/output name. Most cases.
- Mapping widget values by position in the widget array — this is the source of most breakage.
- Filling missing inputs with their default, if they're declared in
INPUT_TYPES["optional"]. Missing required inputs → validation error.
What breaks vs what doesn't
| Change | Breaks saved workflows? | Why |
|---|---|---|
Add a new input to INPUT_TYPES["required"] |
YES | Old workflow has no value for it → validation fails |
Add a new input to INPUT_TYPES["optional"] at the end |
safe | Missing inputs use defaults |
| Add a new widget between existing widgets | YES | Widget array indices shift → values land in wrong widgets |
| Add a new widget at the end | mostly safe | Old workflows just don't have that index → uses default |
| Rename an existing input | YES | Saved value can't map by name |
| Rename the class / node ID | YES | Lookup fails entirely |
| Reorder existing combo options | YES for indexed serialization | Some workflows store combo as index, not string |
| Add new combo option at the end | safe | Existing string values still match |
| Remove an input | soft break | Saved value silently dropped, may lose semantic meaning |
Change RETURN_TYPES order or types |
YES | Downstream connections targeting output[1] now hit wrong type |
| Change a default value | "stealth break" | Old workflows have the old default baked in; new workflows get the new default — silent behavior divergence |
"Field gets messed up" almost always = widget position shift. ComfyUI serializes widgets as
widgets_values: [val0, val1, val2]. Insert a new widget at index 1 → every subsequent value shifts one slot → wrong values land in wrong widgets.
The five rules
- Never rename a registered node ID. When you really must, register both old and new IDs pointing to the same class for a few releases (deprecation alias), then remove the old one with a CHANGELOG note.
- Append new inputs/widgets to the END. Never insert in the middle.
Order in
INPUT_TYPESdict matters because Python preserves insertion order and the frontend uses it. - New inputs go in
INPUT_TYPES["optional"]with a default, not inrequired. Old workflows that don't have the field will use the default; new workflows can wire it up. - Never reorder combo options. Append new options. If you need to remove one, leave it as a hidden alias or deprecation stub for a release first.
- Treat
RETURN_TYPESas immutable. If you need to change outputs, make a new node ID —MyNodeV2. The Comfy ecosystem (KJ Nodes, Was Node Suite, Crystools) does this constantly.
When you can't avoid a breaking change
| Pattern | Example |
|---|---|
| Suffix-version a new node | EasyAIPipeline → EasyAIPipelineV2. Both register, both work. |
| Mark old as deprecated in display name | Old: Easy AI Pipeline (deprecated, use V2) — display name change is fine, ID is what matters |
| Document migration in CHANGELOG | "Old EasyAIPipeline retained; new pipeline work should use EasyAIPipelineV2 which adds X/Y/Z" |
| Keep deprecated nodes for ≥2 minor versions before removing |
This is exactly the pattern used for EasyResize → EasyResize_Koolook
in v0.1.6 (see ../../CHANGELOG.md). Both IDs
register, both load, the bare-name one's display says "deprecated", and
the bare-name one will be removed in a future major release once the
deprecation has had time to propagate.
Concrete pattern — the rename + alias
# In whichever k_*.py file holds the class:
class MyNode:
""" ... existing implementation, unchanged ... """
# ...
# Register BOTH IDs against the same class:
NODE_CLASS_MAPPINGS = {
"MyNode_Koolook": MyNode, # new canonical ID
"MyNode": MyNode, # legacy alias (deprecated)
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyNode_Koolook": "My Node (Koolook)",
"MyNode": "My Node (deprecated, use 'My Node (Koolook)')",
}
That's it. Saved workflows that reference MyNode keep loading. New
workflows pick MyNode_Koolook from the search. The bare-name display
nudges users to switch.
Concrete pattern — adding a new optional input
Suppose EasyAIPipeline currently has these inputs:
"required": {
"shot_duration": ("INT", {...}),
"seed": ("INT", {...}),
"base_path": ("STRING", {...}),
"shot_name": ("STRING", {...}),
"ai_method": ("STRING", {...}),
"version": ("INT", {...}),
},
You want to add a new pass_name field. Do not add it to required
between existing fields — that breaks every saved workflow. Instead:
"required": { ... unchanged ... },
"optional": {
"pass_name": ("STRING", {"default": "", "tooltip": "..."}),
},
- Old workflows that don't have
pass_nameuse the default"". - New workflows that do have it use the value the user sets.
- Output behavior must remain backward-compatible when
pass_name=""(i.e. equivalent to the pre-change behavior). Ifpass_name=""would change the output filename, you need aMyNodeV2instead.
Concrete pattern — output change
Suppose EasyAIPipeline currently returns (output_path,) and you want
to add a (output_path, output_dir) tuple. That's a RETURN_TYPES
change → downstream connections targeting output[1] would hit the new
output unexpectedly (or just exist where they didn't before).
Don't modify the existing class. Make a new one:
class EasyAIPipeline_V2(EasyAIPipeline):
"""Same as EasyAIPipeline, plus emits the resolved output directory."""
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("output_path", "output_dir")
# ... reuse or override the execute method as needed ...
NODE_CLASS_MAPPINGS = {
"EasyAIPipeline": EasyAIPipeline, # unchanged
"EasyAIPipeline_V2": EasyAIPipeline_V2, # new
}
Anti-patterns to refuse
- ❌ "I'll just add the field in the middle for clarity, the dict order doesn't matter." It does. Append.
- ❌ "I'll rename the input to be clearer." Don't. Add a new optional input with the clearer name; deprecate the old one in display only.
- ❌ "It's a small change, no need to bump the node version." If the input/output surface changes in a way that could break a saved workflow → new node ID. Period.
- ❌ "Nobody's using the old node yet." You don't actually know. Default to alias-then-deprecate; cost is one extra dict entry.
Tooling — what we have, what's still missing
- ✅ Workflow-fixture smoke test —
tools/preflight_release.pycheckworkflowswalkstests/workflows/*.json, extracts node IDs by both heuristic and AST-extracted set, and reports any reference that no longer resolves. Thepreflight-releaseskill in.claude/skills/runs all four checks before a release. - ⏳
node-api-changeagent skill — a check that would run before any edit toINPUT_TYPES/RETURN_TYPES/ class names, diff against the rules above, and refuse unsafe changes. Same shape aslicense-pre-check. Not built yet; scoped for the next time someone changes a node's input/output surface.