| # Node versioning β how to change a node without breaking saved workflows |
|
|
| > **β οΈ Scope β OPT-IN for Koolook nodes, MANDATORY for forks.** |
| > Backward compatibility is **not** a default constraint for Koolook-created |
| > nodes (the root `k_*.py` custom nodes); for those, **ignore everything below** |
| > and make the cleanest change β rename/drop/reorder freely β *unless* the |
| > maintainer says `check backward compatibility`. **For anything under |
| > `forks/`** (upstream ports/wrappers, including the Koolook-original nodes |
| > exposed via `SKIP_VERSION_SUFFIX`) **these rules still apply by default** β |
| > forks keep full back-compat discipline with no request needed. See |
| > [`CLAUDE.md`](../../CLAUDE.md) β *Change management*. |
| |
| 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 a |
| **fork** node's `INPUT_TYPES` / `RETURN_TYPES` / `NODE_CLASS_MAPPINGS`, |
| or a **Koolook** node's once `check backward compatibility` has been |
| requested. |
| |
| ## How ComfyUI loads a saved workflow |
| |
| When the frontend opens a workflow JSON, it reconstructs each node by: |
| |
| 1. **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. |
| 2. **Mapping connections by input/output name.** Most cases. |
| 3. **Mapping widget values by *position* in the widget array** β this is |
| the source of most breakage. |
| 4. **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 |
| |
| 1. **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. |
| 2. **Append new inputs/widgets to the END.** Never insert in the middle. |
| Order in `INPUT_TYPES` dict matters because Python preserves insertion |
| order and the frontend uses it. |
| 3. **New inputs go in `INPUT_TYPES["optional"]` with a default**, not in |
| `required`. Old workflows that don't have the field will use the |
| default; new workflows can wire it up. |
| 4. **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. |
| 5. **Treat `RETURN_TYPES` as 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`](../../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 |
|
|
| ```python |
| # 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: |
|
|
| ```python |
| "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: |
|
|
| ```python |
| "required": { ... unchanged ... }, |
| "optional": { |
| "pass_name": ("STRING", {"default": "", "tooltip": "..."}), |
| }, |
| ``` |
|
|
| - Old workflows that don't have `pass_name` use 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). If `pass_name=""` would |
| change the output filename, you need a `MyNodeV2` instead. |
|
|
| ## 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: |
|
|
| ```python |
| 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 β *for forks, or after `check backward compatibility`* |
|
|
| These are anti-patterns for **any fork node**, and for a **Koolook node only |
| once the maintainer has asked** to preserve backward compatibility. For a |
| Koolook node with no such request, every one of them is the *correct* clean |
| change β append-vs-insert is the lone exception below. |
|
|
| - β "I'll just add the field in the middle for clarity, the dict order |
| doesn't matter." It does. Append. *(This one is worth following even |
| without a back-compat request β a mid-list widget insertion silently |
| scrambles the `widgets_values` of any workflow already on disk, including |
| your own dev graphs, for zero benefit. Appending is free.)* |
| - β "I'll rename the input to be clearer." For a fork node, or any node |
| under a back-compat request: add a new optional input with the clearer |
| name and deprecate the old one in display only. For a Koolook node with |
| no request, **just rename it.** |
| - β "It's a small change, no need to bump the node version." For a fork |
| node, or under a back-compat request, a surface change that could break a |
| saved workflow β new node ID. For a Koolook node with no request, change |
| in place and note it in the CHANGELOG. |
| - β "Nobody's using the old node yet." For a fork node, or under a |
| back-compat request, you can't assume that. For a Koolook node with no |
| request you *do* assume it β clean change, move on. |
|
|
| ## Tooling β what we have, what's still missing |
|
|
| - β
**Workflow-fixture smoke test** β `tools/preflight_release.py` |
| check `workflows` walks `tests/workflows/*.json`, extracts node IDs |
| by both heuristic and AST-extracted set, and reports any reference |
| that no longer resolves. The `preflight-release` skill in |
| `.claude/skills/` runs all four checks before a release. |
| - β³ **`node-api-change` agent skill** β a check that would run before |
| any edit to `INPUT_TYPES` / `RETURN_TYPES` / class names, diff |
| against the rules above, and refuse unsafe changes. Same shape as |
| `license-pre-check`. Not built yet; scoped for the next time someone |
| changes a node's input/output surface. |
|
|