mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Finished component sidebar updates, with one small bug remaining and documented
This commit is contained in:
360
dev-docs/reference/UNDO-QUEUE-PATTERNS.md
Normal file
360
dev-docs/reference/UNDO-QUEUE-PATTERNS.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# 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 stack
|
||||
- **`UndoActionGroup`**: 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](./LEARNINGS.md#-critical-undoactiongroupdo-silent-failure-dec-2025) for full technical details.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Simple Single Action (Recommended)
|
||||
|
||||
This is the most common pattern and should be used for 95% of cases.
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
1. `UndoActionGroup` is created with action in constructor (ptr = 0)
|
||||
2. `pushAndDo()` adds it to the queue
|
||||
3. `pushAndDo()` calls `action.do()` which executes immediately
|
||||
4. User can now undo with Cmd+Z
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Multiple Related Actions
|
||||
|
||||
When you need multiple actions in a single undo group:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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**:
|
||||
|
||||
```typescript
|
||||
// ❌ 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:**
|
||||
|
||||
1. `undoGroup.push()` increments internal `ptr` to `actions.length`
|
||||
2. `undoGroup.do()` loops from `ptr` to `actions.length`
|
||||
3. Since they're equal, loop never runs
|
||||
4. 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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
undoGroup.push(
|
||||
undoGroup.do(
|
||||
```
|
||||
|
||||
If you find this pattern, you have the bug. Replace with `pushAndDo`.
|
||||
|
||||
### 3. Verify Success
|
||||
|
||||
After your action:
|
||||
|
||||
```typescript
|
||||
// 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):
|
||||
|
||||
```typescript
|
||||
const undoGroup = new UndoActionGroup({ label: 'Action' });
|
||||
UndoQueue.instance.push(undoGroup);
|
||||
undoGroup.push({ do: () => {...}, undo: () => {...} });
|
||||
undoGroup.do();
|
||||
```
|
||||
|
||||
### After (Fixed):
|
||||
|
||||
```typescript
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'Action',
|
||||
do: () => {...},
|
||||
undo: () => {...}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Example 1: Component Deletion
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
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](./LEARNINGS.md#-critical-undoactiongroupdo-silent-failure-dec-2025) - Full technical explanation of the bug
|
||||
- [COMMON-ISSUES.md](./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)
|
||||
Reference in New Issue
Block a user