9.1 KiB
UndoQueue Usage Patterns
This guide documents the correct patterns for using OpenNoodl's undo system.
Overview
The OpenNoodl undo system consists of two main classes:
UndoQueue: Manages the global undo/redo stackUndoActionGroup: Represents a single undoable action (or group of actions)
Critical Bug Warning
There's a subtle but dangerous bug in UndoActionGroup that causes silent failures. This guide will show you the correct patterns that avoid this bug.
The Golden Rule
✅ ALWAYS USE: UndoQueue.instance.pushAndDo(new UndoActionGroup({...}))
❌ NEVER USE: undoGroup.push({...}); undoGroup.do();
Why? The second pattern fails silently due to an internal pointer bug. See LEARNINGS.md for full technical details.
Pattern 1: Simple Single Action (Recommended)
This is the most common pattern and should be used for 95% of cases.
import { ProjectModel } from '@noodl-models/projectmodel';
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
function renameComponent(component: ComponentModel, newName: string) {
const oldName = component.name;
// ✅ CORRECT - Action executes immediately and is added to undo stack
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Rename ${component.localName} to ${newName}`,
do: () => {
ProjectModel.instance.renameComponent(component, newName);
},
undo: () => {
ProjectModel.instance.renameComponent(component, oldName);
}
})
);
}
What happens:
UndoActionGroupis created with action in constructor (ptr = 0)pushAndDo()adds it to the queuepushAndDo()callsaction.do()which executes immediately- User can now undo with Cmd+Z
Pattern 2: Multiple Related Actions
When you need multiple actions in a single undo group:
function moveFolder(sourcePath: string, targetPath: string) {
const componentsToMove = ProjectModel.instance
.getComponents()
.filter((comp) => comp.name.startsWith(sourcePath + '/'));
const renames: Array<{ component: ComponentModel; oldName: string; newName: string }> = [];
componentsToMove.forEach((comp) => {
const relativePath = comp.name.substring(sourcePath.length);
const newName = targetPath + relativePath;
renames.push({ component: comp, oldName: comp.name, newName });
});
// ✅ CORRECT - Single undo group for multiple related actions
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move folder ${sourcePath} to ${targetPath}`,
do: () => {
renames.forEach(({ component, newName }) => {
ProjectModel.instance.renameComponent(component, newName);
});
},
undo: () => {
renames.forEach(({ component, oldName }) => {
ProjectModel.instance.renameComponent(component, oldName);
});
}
})
);
}
What happens:
- All renames execute as one operation
- Single undo reverts all changes
- Clean, atomic operation
Pattern 3: Building Complex Undo Groups (Advanced)
Sometimes you need to build undo groups dynamically. Use pushAndDo on the group itself:
function complexOperation() {
const undoGroup = new UndoActionGroup({ label: 'Complex operation' });
// Add to queue first
UndoQueue.instance.push(undoGroup);
// ✅ CORRECT - Use pushAndDo on the group, not push + do
undoGroup.pushAndDo({
do: () => {
console.log('First action executes');
// ... do first thing
},
undo: () => {
// ... undo first thing
}
});
// Another action
undoGroup.pushAndDo({
do: () => {
console.log('Second action executes');
// ... do second thing
},
undo: () => {
// ... undo second thing
}
});
}
Key Point: Use undoGroup.pushAndDo(), NOT undoGroup.push() + undoGroup.do()
Anti-Pattern: What NOT to Do
This pattern looks correct but fails silently:
// ❌ WRONG - DO NOT USE
function badRename(component: ComponentModel, newName: string) {
const oldName = component.name;
const undoGroup = new UndoActionGroup({
label: `Rename to ${newName}`
});
UndoQueue.instance.push(undoGroup);
undoGroup.push({
do: () => {
ProjectModel.instance.renameComponent(component, newName);
// ☠️ THIS NEVER RUNS ☠️
},
undo: () => {
ProjectModel.instance.renameComponent(component, oldName);
}
});
undoGroup.do(); // Loop condition is already false
// Result:
// - Function returns successfully ✅
// - Undo/redo stack is populated ✅
// - But the action NEVER executes ❌
// - Component name doesn't change ❌
}
Why it fails:
undoGroup.push()increments internalptrtoactions.lengthundoGroup.do()loops fromptrtoactions.length- Since they're equal, loop never runs
- Action is recorded but never executed
Pattern Comparison Table
| Pattern | Executes? | Undoable? | Use Case |
|---|---|---|---|
UndoQueue.instance.pushAndDo(new UndoActionGroup({do, undo})) |
✅ Yes | ✅ Yes | Use this 95% of the time |
undoGroup.pushAndDo({do, undo}) |
✅ Yes | ✅ Yes | Building complex groups |
UndoQueue.instance.push(undoGroup); undoGroup.do() |
❌ No | ⚠️ Yes* | Never use - silent failure |
undoGroup.push({do, undo}); undoGroup.do() |
❌ No | ⚠️ Yes* | Never use - silent failure |
* Undo/redo works only if action is manually triggered first
Debugging Tips
If your undo action isn't executing:
1. Add Debug Logging
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: 'My Action',
do: () => {
console.log('🔥 ACTION EXECUTING'); // Should print immediately
// ... your action
},
undo: () => {
console.log('↩️ ACTION UNDOING');
// ... undo logic
}
})
);
If 🔥 ACTION EXECUTING doesn't print, you have the push + do bug.
2. Check Your Pattern
Search your code for:
undoGroup.push(
undoGroup.do(
If you find this pattern, you have the bug. Replace with pushAndDo.
3. Verify Success
After your action:
// Should see immediate result
console.log('New name:', component.name); // Should be changed
Migration Guide
If you have existing code using the broken pattern:
Before (Broken):
const undoGroup = new UndoActionGroup({ label: 'Action' });
UndoQueue.instance.push(undoGroup);
undoGroup.push({ do: () => {...}, undo: () => {...} });
undoGroup.do();
After (Fixed):
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: 'Action',
do: () => {...},
undo: () => {...}
})
);
Real-World Examples
Example 1: Component Deletion
function deleteComponent(component: ComponentModel) {
const componentJson = component.toJSON(); // Save for undo
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Delete ${component.name}`,
do: () => {
ProjectModel.instance.removeComponent(component);
},
undo: () => {
const restored = ComponentModel.fromJSON(componentJson);
ProjectModel.instance.addComponent(restored);
}
})
);
}
Example 2: Node Property Change
function setNodeProperty(node: NodeGraphNode, propertyName: string, newValue: any) {
const oldValue = node.parameters[propertyName];
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Change ${propertyName}`,
do: () => {
node.setParameter(propertyName, newValue);
},
undo: () => {
node.setParameter(propertyName, oldValue);
}
})
);
}
Example 3: Drag and Drop (Multiple Items)
function moveComponents(components: ComponentModel[], targetFolder: string) {
const moves = components.map((comp) => ({
component: comp,
oldPath: comp.name,
newPath: `${targetFolder}/${comp.localName}`
}));
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${components.length} components`,
do: () => {
moves.forEach(({ component, newPath }) => {
ProjectModel.instance.renameComponent(component, newPath);
});
},
undo: () => {
moves.forEach(({ component, oldPath }) => {
ProjectModel.instance.renameComponent(component, oldPath);
});
}
})
);
}
See Also
- LEARNINGS.md - Full technical explanation of the bug
- COMMON-ISSUES.md - Troubleshooting guide
packages/noodl-editor/src/editor/src/models/undo-queue-model.ts- Source code
Last Updated: December 2025 (Phase 0 Foundation Stabilization)