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:
@@ -14,33 +14,58 @@
|
||||
┌───────────────────────────┼───────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||
│ EDITOR (GPL) │ │ RUNTIME (MIT) │ │ UI LIBRARY │
|
||||
│ ⚡ EDITOR (GPL) │ │ RUNTIME (MIT) │ │ UI LIBRARY │
|
||||
│ noodl-editor │ │ noodl-runtime │ │ noodl-core-ui │
|
||||
│ │ │ │ │ │
|
||||
│ • Electron app │ │ • Node engine │ │ • React components│
|
||||
│ • React UI │ │ • Data flow │ │ • Storybook │
|
||||
│ • Property panels │ │ • Event system │ │ • Styling │
|
||||
│ (DESKTOP ONLY) │ │ • Data flow │ │ • Storybook (web) │
|
||||
│ • React UI │ │ • Event system │ │ • Styling │
|
||||
│ • Property panels │ │ │ │ │
|
||||
└───────────────────┘ └───────────────────┘ └───────────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌───────────────────┐
|
||||
│ │ VIEWER (MIT) │
|
||||
│ │ 🌐 VIEWER (MIT) │
|
||||
│ │ noodl-viewer-react│
|
||||
│ │ │
|
||||
│ │ • React runtime │
|
||||
│ │ • Visual nodes │
|
||||
│ │ • DOM handling │
|
||||
│ │ (WEB - Runs in │
|
||||
│ │ browser) │
|
||||
│ └───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ PLATFORM LAYER │
|
||||
│ ⚡ PLATFORM LAYER (Electron) │
|
||||
├───────────────────┬───────────────────┬───────────────────────────────┤
|
||||
│ noodl-platform │ platform-electron │ platform-node │
|
||||
│ (abstraction) │ (desktop impl) │ (server impl) │
|
||||
└───────────────────┴───────────────────┴───────────────────────────────┘
|
||||
|
||||
⚡ = Electron Desktop Application (NOT accessible via browser)
|
||||
🌐 = Web Application (runs in browser)
|
||||
```
|
||||
|
||||
## 🖥️ Architecture: Desktop vs Web
|
||||
|
||||
**Critical Distinction for Development:**
|
||||
|
||||
| Component | Runtime | Access Method | Purpose |
|
||||
| ---------------- | ---------------- | ------------------------------------- | ----------------------------- |
|
||||
| **Editor** ⚡ | Electron Desktop | `npm run dev` → auto-launches window | Development environment |
|
||||
| **Viewer** 🌐 | Web Browser | Deployed URL or preview inside editor | User-facing applications |
|
||||
| **Runtime** | Node.js/Browser | Embedded in viewer | Application logic engine |
|
||||
| **Storybook** 🌐 | Web Browser | `npm run start:storybook` → browser | Component library development |
|
||||
|
||||
**Important for Testing:**
|
||||
|
||||
- When working on the **editor**, you're always in Electron
|
||||
- Never try to open `http://localhost:8080` in a browser - that's the webpack dev server internal to Electron
|
||||
- The editor automatically launches as an Electron window when you run `npm run dev`
|
||||
- Use Electron DevTools (View → Toggle Developer Tools) for debugging the editor
|
||||
- Console logs from the editor appear in Electron DevTools, NOT in the terminal
|
||||
|
||||
---
|
||||
|
||||
## 📁 Key Directories
|
||||
@@ -172,14 +197,14 @@ grep -rn "TODO\|FIXME" packages/noodl-editor/src
|
||||
|
||||
### Common Search Targets
|
||||
|
||||
| Looking for... | Search pattern |
|
||||
|----------------|----------------|
|
||||
| Node definitions | `packages/noodl-runtime/src/nodes/` |
|
||||
| React visual nodes | `packages/noodl-viewer-react/src/nodes/` |
|
||||
| UI components | `packages/noodl-core-ui/src/components/` |
|
||||
| Models/state | `packages/noodl-editor/src/editor/src/models/` |
|
||||
| Property panels | `packages/noodl-editor/src/editor/src/views/panels/` |
|
||||
| Tests | `packages/noodl-editor/tests/` |
|
||||
| Looking for... | Search pattern |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| Node definitions | `packages/noodl-runtime/src/nodes/` |
|
||||
| React visual nodes | `packages/noodl-viewer-react/src/nodes/` |
|
||||
| UI components | `packages/noodl-core-ui/src/components/` |
|
||||
| Models/state | `packages/noodl-editor/src/editor/src/models/` |
|
||||
| Property panels | `packages/noodl-editor/src/editor/src/views/panels/` |
|
||||
| Tests | `packages/noodl-editor/tests/` |
|
||||
|
||||
---
|
||||
|
||||
@@ -243,40 +268,40 @@ npx prettier --write "packages/**/*.{ts,tsx}"
|
||||
|
||||
### Configuration
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `package.json` | Root workspace config |
|
||||
| `lerna.json` | Monorepo settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
| `.eslintrc.js` | Linting rules |
|
||||
| `.prettierrc` | Code formatting |
|
||||
| File | Purpose |
|
||||
| --------------- | --------------------- |
|
||||
| `package.json` | Root workspace config |
|
||||
| `lerna.json` | Monorepo settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
| `.eslintrc.js` | Linting rules |
|
||||
| `.prettierrc` | Code formatting |
|
||||
|
||||
### Entry Points
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `noodl-editor/src/main/main.js` | Electron main process |
|
||||
| `noodl-editor/src/editor/src/index.js` | Renderer entry |
|
||||
| `noodl-runtime/noodl-runtime.js` | Runtime engine |
|
||||
| `noodl-viewer-react/index.js` | React runtime |
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------- |
|
||||
| `noodl-editor/src/main/main.js` | Electron main process |
|
||||
| `noodl-editor/src/editor/src/index.js` | Renderer entry |
|
||||
| `noodl-runtime/noodl-runtime.js` | Runtime engine |
|
||||
| `noodl-viewer-react/index.js` | React runtime |
|
||||
|
||||
### Core Models
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `projectmodel.ts` | Project state management |
|
||||
| `nodegraphmodel.ts` | Graph data structure |
|
||||
| `componentmodel.ts` | Component definitions |
|
||||
| `nodelibrary.ts` | Node type registry |
|
||||
| File | Purpose |
|
||||
| ------------------- | ------------------------ |
|
||||
| `projectmodel.ts` | Project state management |
|
||||
| `nodegraphmodel.ts` | Graph data structure |
|
||||
| `componentmodel.ts` | Component definitions |
|
||||
| `nodelibrary.ts` | Node type registry |
|
||||
|
||||
### Important Views
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `nodegrapheditor.ts` | Main canvas editor |
|
||||
| `EditorPage.tsx` | Main page layout |
|
||||
| `NodePicker.tsx` | Node creation panel |
|
||||
| `PropertyEditor/` | Property panels |
|
||||
| File | Purpose |
|
||||
| -------------------- | ------------------- |
|
||||
| `nodegrapheditor.ts` | Main canvas editor |
|
||||
| `EditorPage.tsx` | Main page layout |
|
||||
| `NodePicker.tsx` | Node creation panel |
|
||||
| `PropertyEditor/` | Property panels |
|
||||
|
||||
---
|
||||
|
||||
@@ -375,4 +400,4 @@ npm run rebuild
|
||||
|
||||
---
|
||||
|
||||
*Quick reference card for OpenNoodl development. Print or pin to your IDE!*
|
||||
_Quick reference card for OpenNoodl development. Print or pin to your IDE!_
|
||||
|
||||
@@ -9,6 +9,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Build fails with `Cannot find module '@noodl-xxx/...'`
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Run `npm install` from root directory
|
||||
2. Check if package exists in `packages/`
|
||||
3. Verify tsconfig paths are correct
|
||||
@@ -19,6 +20,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: npm install shows peer dependency warnings
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check if versions are compatible
|
||||
2. Update the conflicting package
|
||||
3. Last resort: `npm install --legacy-peer-deps`
|
||||
@@ -29,6 +31,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Types that worked before now fail
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Run `npx tsc --noEmit` to see all errors
|
||||
2. Check if `@types/*` packages need updating
|
||||
3. Look for breaking changes in updated packages
|
||||
@@ -39,6 +42,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Build starts but never completes
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check for circular imports: `npx madge --circular packages/`
|
||||
2. Increase Node memory: `NODE_OPTIONS=--max_old_space_size=4096`
|
||||
3. Check for infinite loops in build scripts
|
||||
@@ -51,6 +55,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Changes don't appear without full restart
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check webpack dev server is running
|
||||
2. Verify file is being watched (check webpack config)
|
||||
3. Clear browser cache
|
||||
@@ -62,6 +67,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Created a node but it doesn't show up
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify node is exported in `nodelibraryexport.js`
|
||||
2. Check `category` is valid
|
||||
3. Verify no JavaScript errors in node definition
|
||||
@@ -72,6 +78,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Runtime error accessing object properties
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Add null checks: `obj?.property`
|
||||
2. Verify data is loaded before access
|
||||
3. Check async timing issues
|
||||
@@ -82,11 +89,154 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Changed input but output doesn't update
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify `flagOutputDirty()` is called
|
||||
2. Check if batching is interfering
|
||||
3. Verify connection exists in graph
|
||||
4. Check for conditional logic preventing update
|
||||
|
||||
### React Component Not Receiving Events
|
||||
|
||||
**Symptom**: ProjectModel/NodeLibrary events fire but React components don't update
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check if using `useEventListener` hook** (most common issue):
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Always use useEventListener
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
|
||||
// ❌ WRONG - Direct .on() silently fails in React
|
||||
useEffect(() => {
|
||||
ProjectModel.instance.on('event', handler, {});
|
||||
}, []);
|
||||
|
||||
useEventListener(ProjectModel.instance, 'event', handler);
|
||||
```
|
||||
|
||||
2. **Check singleton dependency in useEffect**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Runs once before instance exists
|
||||
useEffect(() => {
|
||||
if (!ProjectModel.instance) return;
|
||||
ProjectModel.instance.on('event', handler, group);
|
||||
}, []); // Empty deps!
|
||||
|
||||
// ✅ RIGHT - Re-runs when instance loads
|
||||
useEffect(() => {
|
||||
if (!ProjectModel.instance) return;
|
||||
ProjectModel.instance.on('event', handler, group);
|
||||
}, [ProjectModel.instance]); // Include singleton!
|
||||
```
|
||||
|
||||
3. **Verify code is loading**:
|
||||
|
||||
- Add `console.log('🔥 Module loaded')` at top of file
|
||||
- If log doesn't appear, clear caches (see Webpack issues below)
|
||||
|
||||
4. **Check event name matches exactly**:
|
||||
- ProjectModel events: `componentRenamed`, `componentAdded`, `componentRemoved`
|
||||
- Case-sensitive, no typos
|
||||
|
||||
**See also**:
|
||||
|
||||
- [LEARNINGS.md - React + EventDispatcher](./LEARNINGS.md#-critical-react--eventdispatcher-incompatibility-phase-0-dec-2025)
|
||||
- [LEARNINGS.md - Singleton Timing](./LEARNINGS.md#-critical-singleton-dependency-timing-in-useeffect-dec-2025)
|
||||
|
||||
### Undo Action Doesn't Execute
|
||||
|
||||
**Symptom**: Action returns success and appears in undo history, but nothing happens
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check if using broken pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Silent failure due to ptr bug
|
||||
const undoGroup = new UndoActionGroup({ label: 'Action' });
|
||||
UndoQueue.instance.push(undoGroup);
|
||||
undoGroup.push({ do: () => {...}, undo: () => {...} });
|
||||
undoGroup.do(); // NEVER EXECUTES
|
||||
|
||||
// ✅ RIGHT - Use pushAndDo
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'Action',
|
||||
do: () => {...},
|
||||
undo: () => {...}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
2. **Add debug logging**:
|
||||
|
||||
```typescript
|
||||
do: () => {
|
||||
console.log('🔥 ACTION EXECUTING'); // Should print immediately
|
||||
// Your action here
|
||||
}
|
||||
```
|
||||
|
||||
If log doesn't print, you have the ptr bug.
|
||||
|
||||
3. **Search codebase for broken pattern**:
|
||||
```bash
|
||||
grep -r "undoGroup.push" packages/
|
||||
grep -r "undoGroup.do()" packages/
|
||||
```
|
||||
If these appear together, fix them.
|
||||
|
||||
**See also**:
|
||||
|
||||
- [UNDO-QUEUE-PATTERNS.md](./UNDO-QUEUE-PATTERNS.md) - Complete guide
|
||||
- [LEARNINGS.md - UndoActionGroup](./LEARNINGS.md#-critical-undoactiongroupdo-silent-failure-dec-2025)
|
||||
|
||||
### Webpack Cache Preventing Code Changes
|
||||
|
||||
**Symptom**: Code changes not appearing despite save/restart
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Verify code is loading** (add module marker):
|
||||
|
||||
```typescript
|
||||
// At top of file
|
||||
console.log('🔥 MyFile.ts LOADED - Version 2.0');
|
||||
```
|
||||
|
||||
If this doesn't appear in console, it's a cache issue.
|
||||
|
||||
2. **Nuclear cache clear** (when standard restart fails):
|
||||
|
||||
```bash
|
||||
# Kill processes
|
||||
killall node
|
||||
killall Electron
|
||||
|
||||
# Clear ALL caches
|
||||
rm -rf packages/noodl-editor/node_modules/.cache
|
||||
rm -rf ~/Library/Application\ Support/Electron
|
||||
rm -rf ~/Library/Application\ Support/OpenNoodl # macOS
|
||||
|
||||
# Restart
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Check build timestamp**:
|
||||
|
||||
- Look for `🔥 BUILD TIMESTAMP:` in console
|
||||
- If timestamp is old, caching is active
|
||||
|
||||
4. **Verify in Sources tab**:
|
||||
- Open Chrome DevTools
|
||||
- Go to Sources tab
|
||||
- Find your file
|
||||
- Check if changes are there
|
||||
|
||||
**See also**: [LEARNINGS.md - Webpack Caching](./LEARNINGS.md#webpack-5-persistent-caching-issues-dec-2025)
|
||||
|
||||
## Editor Issues
|
||||
|
||||
### Preview Not Loading
|
||||
@@ -94,6 +244,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Preview panel is blank or shows error
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check browser console for errors
|
||||
2. Verify viewer runtime is built
|
||||
3. Check for JavaScript errors in project
|
||||
@@ -104,6 +255,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Selected node but no properties shown
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify node has `inputs` defined
|
||||
2. Check `group` values are set
|
||||
3. Look for errors in property panel code
|
||||
@@ -114,6 +266,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Node graph is slow/laggy
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Reduce number of visible nodes
|
||||
2. Check for expensive render operations
|
||||
3. Verify no infinite update loops
|
||||
@@ -126,6 +279,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Complex conflicts in lock file
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Accept either version
|
||||
2. Run `npm install` to regenerate
|
||||
3. Commit the regenerated lock file
|
||||
@@ -135,6 +289,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Git warns about large files
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check `.gitignore` includes build outputs
|
||||
2. Verify `node_modules` not committed
|
||||
3. Use Git LFS for large assets if needed
|
||||
@@ -146,6 +301,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Tests hang or timeout
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check for unresolved promises
|
||||
2. Verify mocks are set up correctly
|
||||
3. Increase timeout if legitimately slow
|
||||
@@ -156,6 +312,7 @@ Solutions to frequently encountered problems when developing OpenNoodl.
|
||||
**Symptom**: Snapshot doesn't match
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Review the diff carefully
|
||||
2. If change is intentional: `npm test -- -u`
|
||||
3. If unexpected, investigate component changes
|
||||
@@ -203,7 +360,8 @@ model.on('*', (event, data) => {
|
||||
|
||||
**Cause**: Infinite recursion or circular dependency
|
||||
|
||||
**Fix**:
|
||||
**Fix**:
|
||||
|
||||
1. Check for circular imports
|
||||
2. Add base case to recursive functions
|
||||
3. Break dependency cycles
|
||||
@@ -213,6 +371,7 @@ model.on('*', (event, data) => {
|
||||
**Cause**: Temporal dead zone with `let`/`const`
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Check import order
|
||||
2. Move declaration before usage
|
||||
3. Check for circular imports
|
||||
@@ -222,6 +381,7 @@ model.on('*', (event, data) => {
|
||||
**Cause**: Syntax error or wrong file type
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Check file extension matches content
|
||||
2. Verify JSON is valid
|
||||
3. Check for missing brackets/quotes
|
||||
@@ -231,6 +391,7 @@ model.on('*', (event, data) => {
|
||||
**Cause**: Missing file or wrong path
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Verify file exists
|
||||
2. Check path is correct (case-sensitive)
|
||||
3. Ensure build step completed
|
||||
|
||||
@@ -2,6 +2,66 @@
|
||||
|
||||
This document captures important discoveries and gotchas encountered during OpenNoodl development.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: React + EventDispatcher Incompatibility (Phase 0, Dec 2025)
|
||||
|
||||
### The Silent Killer: Direct `.on()` Subscriptions in React
|
||||
|
||||
**Context**: Phase 0 Foundation Stabilization discovered a critical, silent failure mode that was blocking all React migration work.
|
||||
|
||||
**The Problem**: EventDispatcher's `.on()` method **silently fails** when used directly in React components. Events are emitted, but React never receives them. No errors, no warnings, just silence.
|
||||
|
||||
**Root Cause**: Fundamental incompatibility between:
|
||||
|
||||
- EventDispatcher's context-object-based cleanup pattern
|
||||
- React's closure-based lifecycle management
|
||||
|
||||
**The Broken Pattern** (compiles and runs without errors):
|
||||
|
||||
```typescript
|
||||
// ❌ THIS SILENTLY FAILS - DO NOT USE
|
||||
function MyComponent() {
|
||||
useEffect(() => {
|
||||
const context = {};
|
||||
ProjectModel.instance.on('componentRenamed', handler, context);
|
||||
return () => ProjectModel.instance.off(context); // Context reference doesn't match
|
||||
}, []);
|
||||
|
||||
// Events are emitted but NEVER received
|
||||
// Hours of debugging later...
|
||||
}
|
||||
```
|
||||
|
||||
**The Solution** - Always use `useEventListener` hook:
|
||||
|
||||
```typescript
|
||||
// ✅ THIS WORKS - ALWAYS USE THIS
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
|
||||
function MyComponent() {
|
||||
useEventListener(ProjectModel.instance, 'componentRenamed', (data) => {
|
||||
// Events received correctly!
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters**:
|
||||
|
||||
- Wasted 10+ hours per React migration debugging this
|
||||
- Affects ALL EventDispatcher usage in React (ProjectModel, NodeLibrary, WarningsModel, etc.)
|
||||
- Silent failures are the worst kind of bug
|
||||
|
||||
**Full Documentation**:
|
||||
|
||||
- Pattern Guide: `dev-docs/tasks/phase-0-foundation-stabalisation/TASK-011-react-event-pattern-guide/GOLDEN-PATTERN.md`
|
||||
- Investigation: `dev-docs/tasks/phase-0-foundation-stabalisation/TASK-008-eventdispatcher-react-investigation/`
|
||||
- Also in: `.clinerules` (Section: React + EventDispatcher Integration)
|
||||
|
||||
**Keywords**: EventDispatcher, React, useEventListener, silent failure, event subscription, Phase 0
|
||||
|
||||
---
|
||||
|
||||
## React Hooks & EventDispatcher Integration (Dec 2025)
|
||||
|
||||
### Problem: EventDispatcher Events Not Reaching React Hooks
|
||||
@@ -146,3 +206,729 @@ function MyComponent() {
|
||||
**Keywords**: React 19, useEffect, dependencies, array, Object.is, spread operator, hook lifecycle
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: Singleton Dependency Timing in useEffect (Dec 2025)
|
||||
|
||||
### The Silent Subscriber: Missing Singleton Dependencies
|
||||
|
||||
**Context**: Phase 0 completion - Final bug preventing EventDispatcher events from reaching React components.
|
||||
|
||||
**The Problem**: Components that subscribe to singleton instances (like `ProjectModel.instance`) in useEffect often mount **before** the singleton is initialized. With an empty dependency array, the effect only runs once when the instance is `null/undefined`, and never re-runs when the instance is set.
|
||||
|
||||
**Symptom**: Events are emitted, logs show event firing, but React components never receive them.
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Subscribes before instance exists, never re-subscribes
|
||||
function MyComponent() {
|
||||
useEffect(() => {
|
||||
console.log('Setting up subscriptions');
|
||||
if (!ProjectModel.instance) {
|
||||
console.log('Instance is null'); // This prints
|
||||
return;
|
||||
}
|
||||
|
||||
ProjectModel.instance.on('event', handler, group);
|
||||
// This NEVER executes because instance is null at mount
|
||||
// and useEffect never runs again!
|
||||
}, []); // Empty deps = only runs once at mount
|
||||
}
|
||||
```
|
||||
|
||||
**Timeline of Failure**:
|
||||
|
||||
1. Component mounts → useEffect runs
|
||||
2. `ProjectModel.instance` is `null` (project not loaded yet)
|
||||
3. Early return, no subscription
|
||||
4. Project loads → `ProjectModel.instance` gets set
|
||||
5. **useEffect doesn't re-run** (instance not in deps)
|
||||
6. Events fire but nobody's listening 🦗
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Re-subscribes when instance changes from null to defined
|
||||
function MyComponent() {
|
||||
useEffect(() => {
|
||||
console.log('Setting up subscriptions');
|
||||
if (!ProjectModel.instance) {
|
||||
console.log('Instance is null, will retry when available');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = { id: 'mySubscription' };
|
||||
ProjectModel.instance.on('event', handler, group);
|
||||
console.log('Subscribed successfully!');
|
||||
|
||||
return () => {
|
||||
if (ProjectModel.instance) {
|
||||
ProjectModel.instance.off(group);
|
||||
}
|
||||
};
|
||||
}, [ProjectModel.instance]); // RE-RUNS when instance changes!
|
||||
}
|
||||
```
|
||||
|
||||
**Critical Rule**: **Always include singleton instances in useEffect dependencies if you're subscribing to them.**
|
||||
|
||||
**Affected Singletons**:
|
||||
|
||||
- `ProjectModel.instance`
|
||||
- `NodeLibrary.instance`
|
||||
- `WarningsModel.instance`
|
||||
- `EventDispatcher.instance`
|
||||
- `UndoQueue.instance`
|
||||
|
||||
**Location**:
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts` (line 76)
|
||||
- Any React component using EventDispatcher with singleton instances
|
||||
|
||||
**Keywords**: singleton, useEffect, dependencies, timing, ProjectModel, EventDispatcher, subscription, React lifecycle
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: UndoActionGroup.do() Silent Failure (Dec 2025)
|
||||
|
||||
### The Invisible Bug: Actions That Don't Execute
|
||||
|
||||
**Context**: Phase 0 completion - Discovered why `ProjectModel.renameComponent()` was never being called despite the undo system reporting success.
|
||||
|
||||
**The Problem**: `UndoActionGroup.push()` followed by `undoGroup.do()` **silently fails to execute** due to an internal pointer bug. The action is recorded for undo/redo, but never actually executes.
|
||||
|
||||
**Root Cause**: `UndoActionGroup` maintains an internal `ptr` (pointer) that tracks which actions have been executed:
|
||||
|
||||
- `push()` increments `ptr` to `actions.length`
|
||||
- `do()` loops from `ptr` to `actions.length`
|
||||
- But if `ptr === actions.length`, the loop never runs!
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Action recorded but NEVER executes
|
||||
const undoGroup = new UndoActionGroup({
|
||||
label: 'Rename component'
|
||||
});
|
||||
|
||||
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 (ptr === actions.length)
|
||||
|
||||
// Result:
|
||||
// - Returns true ✅
|
||||
// - Undo/redo works ✅
|
||||
// - But initial action NEVER executes ❌
|
||||
```
|
||||
|
||||
**Why It's Dangerous**:
|
||||
|
||||
- No errors or warnings
|
||||
- Returns success
|
||||
- Undo/redo actually works (if you manually trigger the action first)
|
||||
- Can waste hours debugging because everything "looks correct"
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Use pushAndDo pattern (action in constructor)
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'Rename component',
|
||||
do: () => {
|
||||
ProjectModel.instance.renameComponent(component, newName);
|
||||
},
|
||||
undo: () => {
|
||||
ProjectModel.instance.renameComponent(component, oldName);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// This works because:
|
||||
// 1. Action added in constructor (ptr still 0)
|
||||
// 2. pushAndDo() calls do() which loops from 0 to 1
|
||||
// 3. Action executes! 🎉
|
||||
```
|
||||
|
||||
**Alternative Pattern** (if you need to build complex undo groups):
|
||||
|
||||
```typescript
|
||||
// ✅ ALSO RIGHT - Use pushAndDo on individual actions
|
||||
const undoGroup = new UndoActionGroup({ label: 'Complex operation' });
|
||||
UndoQueue.instance.push(undoGroup);
|
||||
|
||||
undoGroup.pushAndDo({
|
||||
// Note: pushAndDo, not push!
|
||||
do: () => {
|
||||
/* first action */
|
||||
},
|
||||
undo: () => {
|
||||
/* undo first */
|
||||
}
|
||||
});
|
||||
|
||||
undoGroup.pushAndDo({
|
||||
// Note: pushAndDo, not push!
|
||||
do: () => {
|
||||
/* second action */
|
||||
},
|
||||
undo: () => {
|
||||
/* undo second */
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Critical Rule**: **Never use `undoGroup.push()` + `undoGroup.do()`. Always use `UndoQueue.instance.pushAndDo()` or `undoGroup.pushAndDo()`.**
|
||||
|
||||
**Code Evidence** (from `undo-queue-model.ts` lines 108-115):
|
||||
|
||||
```typescript
|
||||
do() {
|
||||
for (var i = this.ptr; i < this.actions.length; i++) {
|
||||
// If ptr === actions.length, this loop never runs!
|
||||
var a = this.actions[i];
|
||||
a.do && a.do();
|
||||
}
|
||||
this.ptr = this.actions.length;
|
||||
}
|
||||
```
|
||||
|
||||
**Location**:
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/models/undo-queue-model.ts`
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts` (line 174)
|
||||
|
||||
**Detection**: Add debug logging to your action's `do()` function. If it never prints, you have this bug.
|
||||
|
||||
**Keywords**: UndoQueue, UndoActionGroup, silent failure, do, push, pushAndDo, undo, redo, pointer bug
|
||||
|
||||
---
|
||||
|
||||
## PopupLayer API Confusion: showPopup vs showPopout (Dec 2025)
|
||||
|
||||
### The Invisible Menu: Wrong API, Silent Failure
|
||||
|
||||
**Context**: TASK-008 ComponentsPanel menus - Plus button menu logged "Popup shown successfully" but nothing appeared on screen.
|
||||
|
||||
**The Problem**: PopupLayer has **two different methods** for displaying overlays, each with different parameters and behaviors. Using the wrong one causes silent failures where the popup/popout doesn't appear, but no error is thrown.
|
||||
|
||||
**Root Cause**: API confusion between modals and attached menus.
|
||||
|
||||
**The Two APIs**:
|
||||
|
||||
```typescript
|
||||
// 1. showPopup() - For centered modals/dialogs
|
||||
PopupLayer.instance.showPopup({
|
||||
content: popupObject, // Direct object reference
|
||||
position: 'screen-center', // Only supports 'screen-center'
|
||||
isBackgroundDimmed: true // Optional: dims background for modals
|
||||
});
|
||||
|
||||
// 2. showPopout() - For attached dropdowns/menus
|
||||
PopupLayer.instance.showPopout({
|
||||
content: { el: jQueryElement }, // Must wrap in { el: ... }
|
||||
attachTo: $(element), // jQuery element to attach to
|
||||
position: 'bottom', // Supports 'bottom'|'top'|'left'|'right'
|
||||
arrowColor: '313131' // Optional: arrow indicator color
|
||||
});
|
||||
```
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - showPopup doesn't support position: 'bottom'
|
||||
const menu = new PopupMenu({ items, owner: PopupLayer.instance });
|
||||
menu.render();
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: menu,
|
||||
attachTo: $(buttonRef.current),
|
||||
position: 'bottom' // showPopup ignores this!
|
||||
});
|
||||
// Logs success, but menu never appears
|
||||
```
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - showPopout for attached menus
|
||||
const menu = new PopupMenu({ items, owner: PopupLayer.instance });
|
||||
menu.render();
|
||||
|
||||
PopupLayer.instance.showPopout({
|
||||
content: { el: menu.el }, // Wrap in { el: ... }
|
||||
attachTo: $(buttonRef.current),
|
||||
position: 'bottom'
|
||||
});
|
||||
// Menu appears below button with arrow indicator!
|
||||
```
|
||||
|
||||
**Rule of Thumb**:
|
||||
|
||||
- **Use `showPopup()`** for:
|
||||
- Modal dialogs (confirmation, input, etc.)
|
||||
- Centered popups
|
||||
- When you need `isBackgroundDimmed`
|
||||
- **Use `showPopout()`** for:
|
||||
- Dropdown menus
|
||||
- Context menus
|
||||
- Tooltips
|
||||
- Anything attached to a specific element
|
||||
|
||||
**Common Gotchas**:
|
||||
|
||||
1. **Content format differs**:
|
||||
|
||||
- `showPopup()` takes direct object: `content: popup`
|
||||
- `showPopout()` requires wrapper: `content: { el: popup.el }`
|
||||
|
||||
2. **Position values differ**:
|
||||
|
||||
- `showPopup()` only supports `'screen-center'`
|
||||
- `showPopout()` supports `'bottom'|'top'|'left'|'right'`
|
||||
|
||||
3. **No error on wrong usage** - silent failure is the symptom
|
||||
|
||||
**Location**:
|
||||
|
||||
- Type definitions: `packages/noodl-editor/src/editor/src/views/popuplayer.d.ts`
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx` (line 157)
|
||||
|
||||
**Related Issues**:
|
||||
|
||||
- **Template popup visibility**: Also needed `isBackgroundDimmed: true` flag to make modal properly visible with dimmed background
|
||||
|
||||
**Detection**: If popup/popout logs success but doesn't appear, check:
|
||||
|
||||
1. Are you using the right API method?
|
||||
2. Is the content format correct for that API?
|
||||
3. Is the position value supported by that API?
|
||||
|
||||
**Keywords**: PopupLayer, showPopup, showPopout, menu, dropdown, modal, position, silent failure, UI
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: React Button Clicks vs Cursor-Based Menu Positioning (Dec 2025)
|
||||
|
||||
### The Button Click Nightmare: When Menus Just Won't Work
|
||||
|
||||
**Context**: TASK-008 ComponentsPanel menus - Spent hours trying to show a dropdown menu from a plus button click. Multiple approaches all failed in different spectacular ways.
|
||||
|
||||
**The Problem**: `showContextMenuInPopup()` utility (which works perfectly for right-click context menus) **completely fails** when triggered from a button click event. The fundamental issue is that this utility uses `screen.getCursorScreenPoint()` for positioning, which gives you the cursor position at the _moment the function runs_, not where the button is located.
|
||||
|
||||
**Timeline of Failed Attempts**:
|
||||
|
||||
1. **Attempt 1: showContextMenuInPopup() from button click**
|
||||
|
||||
- **Result**: Silent failure - no menu appears, no errors
|
||||
- **Why**: Uses `screen.getCursorScreenPoint()` which gives cursor position after the click moved away from the button
|
||||
- **Duration**: 1+ hours debugging
|
||||
|
||||
2. **Attempt 2: PopupLayer.showPopout() with button ref**
|
||||
|
||||
- **Result**: Silent failures despite "success" logs
|
||||
- **Why**: Content format issues, API confusion
|
||||
- **Duration**: 1+ hours debugging
|
||||
|
||||
3. **Attempt 3: NewPopupLayer.PopupMenu constructor**
|
||||
|
||||
- **Result**: Runtime error "NewPopupLayer.PopupMenu is not a constructor"
|
||||
- **Why**: PopupMenu not properly exported/accessible
|
||||
- **Duration**: 30 minutes debugging
|
||||
|
||||
4. **Attempt 4: Got PopupMenu to render after fixing imports**
|
||||
|
||||
- **Result**: Menu appeared, but click handlers didn't fire
|
||||
- **Why**: Event delegation issues in legacy jQuery code
|
||||
- **Duration**: 1+ hours debugging, multiple cache clears
|
||||
|
||||
5. **Pragmatic Solution: Remove button, use right-click on empty space**
|
||||
- **Result**: Works perfectly using proven showContextMenuInPopup() pattern
|
||||
- **Why**: Right-click naturally provides cursor position for menu positioning
|
||||
|
||||
**The Core Issue**: React + Electron menu positioning from button clicks is fundamentally problematic:
|
||||
|
||||
```typescript
|
||||
// ❌ FAILS - Cursor has moved away from button by the time this runs
|
||||
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
showContextMenuInPopup({
|
||||
items: menuItems,
|
||||
width: MenuDialogWidth.Default
|
||||
});
|
||||
// Internally calls screen.getCursorScreenPoint()
|
||||
// But cursor is no longer over the button!
|
||||
// Menu appears at random location or not at all
|
||||
};
|
||||
|
||||
// ✅ WORKS - Cursor is naturally at right position
|
||||
const handleRightClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
showContextMenuInPopup({
|
||||
items: menuItems,
|
||||
width: MenuDialogWidth.Default
|
||||
});
|
||||
// Cursor is exactly where user right-clicked
|
||||
// Menu appears at correct location
|
||||
};
|
||||
```
|
||||
|
||||
**Why Button Clicks Fail**:
|
||||
|
||||
1. User clicks button
|
||||
2. React synthetic event fires
|
||||
3. Event propagates through React and into Electron
|
||||
4. By the time `showContextMenuInPopup()` runs, cursor has moved
|
||||
5. `screen.getCursorScreenPoint()` gives wrong position
|
||||
6. Menu either doesn't appear or appears in wrong location
|
||||
|
||||
**Why Right-Click Works**:
|
||||
|
||||
1. User right-clicks
|
||||
2. Context menu event fires
|
||||
3. Cursor is still exactly where user clicked
|
||||
4. `screen.getCursorScreenPoint()` gives correct position
|
||||
5. Menu appears at cursor location (expected UX)
|
||||
|
||||
**The Working Pattern**:
|
||||
|
||||
```typescript
|
||||
// In ComponentsPanelReact.tsx
|
||||
const handleTreeContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const templates = ComponentTemplates.instance.getTemplates({
|
||||
forRuntimeType: 'browser'
|
||||
});
|
||||
|
||||
const items: TSFixme[] = templates.map((template) => ({
|
||||
icon: template.icon,
|
||||
label: `Create ${template.label}`,
|
||||
onClick: () => handleAddComponent(template)
|
||||
}));
|
||||
|
||||
items.push({
|
||||
icon: IconName.FolderClosed,
|
||||
label: 'Create Folder',
|
||||
onClick: () => handleAddFolder()
|
||||
});
|
||||
|
||||
showContextMenuInPopup({
|
||||
items,
|
||||
width: MenuDialogWidth.Default
|
||||
});
|
||||
},
|
||||
[handleAddComponent, handleAddFolder]
|
||||
);
|
||||
|
||||
// Attach to tree container for empty space clicks
|
||||
<div className={css['Tree']} onContextMenu={handleTreeContextMenu}>
|
||||
```
|
||||
|
||||
**PopupMenu Constructor Issues**:
|
||||
|
||||
When trying direct PopupMenu usage, encountered:
|
||||
|
||||
```typescript
|
||||
// ❌ FAILS - "PopupMenu is not a constructor"
|
||||
import NewPopupLayer from '../popuplayer/popuplayer';
|
||||
|
||||
const menu = new NewPopupLayer.PopupMenu({ items });
|
||||
|
||||
// Even after fixing imports, menu rendered but clicks didn't work
|
||||
// Legacy jQuery event delegation issues in React context
|
||||
```
|
||||
|
||||
**Critical Lessons**:
|
||||
|
||||
1. **Never use button clicks to trigger showContextMenuInPopup()** - cursor positioning will be wrong
|
||||
2. **Right-click context menus are reliable** - cursor position is naturally correct
|
||||
3. **Legacy PopupLayer/PopupMenu integration with React is fragile** - avoid if possible
|
||||
4. **When something fails repeatedly with the same pattern, change the approach** - "this is the renaming task all over again"
|
||||
5. **Pragmatic UX is better than broken UX** - right-click on empty space works fine
|
||||
|
||||
**UX Implications**:
|
||||
|
||||
- Plus buttons for dropdown menus are problematic in Electron
|
||||
- Right-click context menus are more reliable
|
||||
- Users can right-click on components, folders, or empty space to access create menu
|
||||
- This pattern is actually more discoverable (common in native apps)
|
||||
|
||||
**Location**:
|
||||
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx`
|
||||
- Utility: `packages/noodl-editor/src/editor/src/views/ShowContextMenuInPopup.tsx`
|
||||
- Task: `dev-docs/tasks/phase-2/TASK-008-componentspanel-menus-and-sheets/`
|
||||
|
||||
**References**:
|
||||
|
||||
- ComponentItem.tsx - working right-click context menu example
|
||||
- FolderItem.tsx - working right-click context menu example
|
||||
|
||||
**Detection**: If showContextMenuInPopup() works from right-click but not from button click, you have this issue.
|
||||
|
||||
**Keywords**: showContextMenuInPopup, cursor position, button click, right-click, context menu, Electron, React, PopupMenu, menu positioning, UX
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: Mutable Data Sources + React useMemo (Dec 2025)
|
||||
|
||||
### Problem: useMemo Not Recalculating Despite Dependency Change
|
||||
|
||||
**Context**: TASK-008 Sheet creation - New sheets weren't appearing in dropdown despite event received and updateCounter state changing.
|
||||
|
||||
**Root Cause**: `ProjectModel.getComponents()` returns the **same array reference** each time. When a component is added, the array is mutated (push) rather than replaced. React's `Object.is()` comparison sees the same reference and skips recalculation.
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Same array reference, useMemo skips recalculation
|
||||
const rawComponents = useMemo(() => {
|
||||
if (!ProjectModel.instance) return [];
|
||||
return ProjectModel.instance.getComponents(); // Returns same mutated array
|
||||
}, [updateCounter]); // Even when updateCounter changes!
|
||||
|
||||
// Dependent memos never run because rawComponents reference is unchanged
|
||||
const sheets = useMemo(() => {
|
||||
// This never executes after component added
|
||||
}, [rawComponents]);
|
||||
```
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Spread creates new array reference, forces recalculation
|
||||
const rawComponents = useMemo(() => {
|
||||
if (!ProjectModel.instance) return [];
|
||||
return [...ProjectModel.instance.getComponents()]; // New reference!
|
||||
}, [updateCounter]);
|
||||
```
|
||||
|
||||
**Why This Happens**:
|
||||
|
||||
1. `getComponents()` returns the internal array (same reference)
|
||||
2. When component is added, array is mutated with `push()`
|
||||
3. `Object.is(oldArray, newArray)` returns `true` (same reference)
|
||||
4. useMemo thinks nothing changed, skips recalculation
|
||||
5. Spreading `[...array]` creates new reference → forces recalculation
|
||||
|
||||
**Critical Rule**: When consuming mutable data sources (EventDispatcher models, etc.) in useMemo, **always spread arrays** to create new references.
|
||||
|
||||
**Affected Patterns**:
|
||||
|
||||
- `ProjectModel.instance.getComponents()`
|
||||
- Any `Model.getX()` that returns internal arrays
|
||||
- Collections that mutate rather than replace
|
||||
|
||||
**Location**: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts`
|
||||
|
||||
**Keywords**: useMemo, array reference, Object.is, mutable data, spread operator, React, recalculation, EventDispatcher
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: HTML5 DnD vs Mouse-Based Dragging - onDrop Never Fires (Dec 2025)
|
||||
|
||||
### The Silent Drop: When onDrop Never Triggers
|
||||
|
||||
**Context**: TASK-008C ComponentsPanel drag-drop system - All drop handlers existed and appeared correct, but drops never completed. Items would snap back to origin.
|
||||
|
||||
**The Problem**: The drag-drop implementation used `onDrop` React event handlers, but the underlying PopupLayer drag system uses **mouse-based dragging**, not **HTML5 Drag-and-Drop API**. The HTML5 `onDrop` event **never fires** for mouse-based drag systems.
|
||||
|
||||
**Root Cause**: Fundamental API mismatch between the drag initiation system and drop detection.
|
||||
|
||||
**The Two Drag Systems**:
|
||||
|
||||
1. **HTML5 Drag-and-Drop API**:
|
||||
|
||||
- Uses `draggable="true"` attribute
|
||||
- Events: `ondragstart`, `ondragenter`, `ondragover`, `ondragleave`, `ondrop`
|
||||
- Native browser implementation with built-in ghost image
|
||||
- `onDrop` fires when dropping a dragged element
|
||||
|
||||
2. **Mouse-Based Dragging (PopupLayer)**:
|
||||
- Uses `onMouseDown`, `onMouseMove`, `onMouseUp`
|
||||
- Custom implementation that moves a label element with cursor
|
||||
- `onDrop` **never fires** - must use `onMouseUp` to detect drop
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - onDrop never fires for PopupLayer drag system
|
||||
const handleDrop = useCallback(() => {
|
||||
if (isDropTarget && onDrop) {
|
||||
const node: TreeNode = { type: 'component', data: component };
|
||||
onDrop(node); // Never reached!
|
||||
setIsDropTarget(false);
|
||||
}
|
||||
}, [isDropTarget, component, onDrop]);
|
||||
|
||||
// JSX
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter} // ✅ Works - sets isDropTarget
|
||||
onMouseLeave={handleMouseLeave} // ✅ Works - clears isDropTarget
|
||||
onDrop={handleDrop} // ❌ NEVER FIRES
|
||||
>
|
||||
```
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Use onMouseUp to trigger drop
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
dragStartPos.current = null;
|
||||
|
||||
// If this item is a valid drop target, execute the drop
|
||||
if (isDropTarget && onDrop) {
|
||||
e.stopPropagation(); // Prevent bubble to parent
|
||||
const node: TreeNode = { type: 'component', data: component };
|
||||
onDrop(node);
|
||||
setIsDropTarget(false);
|
||||
}
|
||||
},
|
||||
[isDropTarget, component, onDrop]
|
||||
);
|
||||
|
||||
// JSX
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter} // ✅ Sets isDropTarget
|
||||
onMouseLeave={handleMouseLeave} // ✅ Clears isDropTarget
|
||||
onMouseUp={handleMouseUp} // ✅ Triggers drop when isDropTarget
|
||||
>
|
||||
```
|
||||
|
||||
**Why This Works**:
|
||||
|
||||
1. User starts drag via `onMouseDown` + `onMouseMove` (5px threshold) → `PopupLayer.startDragging()`
|
||||
2. User hovers over target → `onMouseEnter` sets `isDropTarget = true`
|
||||
3. User releases mouse → `onMouseUp` checks `isDropTarget` and executes drop
|
||||
4. `e.stopPropagation()` prevents event bubbling to parent containers
|
||||
|
||||
**Root Drop Zone Pattern**:
|
||||
|
||||
For dropping onto empty space (background), use event bubbling:
|
||||
|
||||
```typescript
|
||||
// In parent container
|
||||
const handleTreeMouseUp = useCallback(() => {
|
||||
const PopupLayer = require('@noodl-views/popuplayer');
|
||||
|
||||
// If we're dragging and no specific item claimed the drop, it's a root drop
|
||||
if (draggedItem && PopupLayer.instance.isDragging()) {
|
||||
handleDropOnRoot(draggedItem);
|
||||
}
|
||||
}, [draggedItem, handleDropOnRoot]);
|
||||
|
||||
// JSX
|
||||
<div className={css['Tree']} onMouseUp={handleTreeMouseUp}>
|
||||
{/* Tree items call e.stopPropagation() on valid drops */}
|
||||
{/* If no item stops propagation, this handler catches it */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Critical Rule**: **If using PopupLayer's drag system, always use `onMouseUp` for drop detection, never `onDrop`.**
|
||||
|
||||
**Detection**: If drops appear to work (visual feedback shows) but never complete (items snap back), check if you're using `onDrop` instead of `onMouseUp`.
|
||||
|
||||
**Location**:
|
||||
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/ComponentItem.tsx`
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/FolderItem.tsx`
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx`
|
||||
- Task: `dev-docs/tasks/phase-2/TASK-008-componentspanel-menus-and-sheets/TASK-008C-drag-drop-system.md`
|
||||
|
||||
**Keywords**: onDrop, onMouseUp, HTML5 DnD, drag-and-drop, PopupLayer, mouse events, drop handler, snap back
|
||||
|
||||
---
|
||||
|
||||
## 🔥 CRITICAL: PopupLayer.dragCompleted() - Not endDrag() (Dec 2025)
|
||||
|
||||
### Wrong Method Name Causes TypeError
|
||||
|
||||
**Context**: TASK-008C ComponentsPanel drag-drop system - After fixing the onDrop→onMouseUp issue, drops still failed with a TypeError.
|
||||
|
||||
**The Error**:
|
||||
|
||||
```
|
||||
TypeError: PopupLayer.instance.endDrag is not a function
|
||||
```
|
||||
|
||||
**The Problem**: Code was calling `PopupLayer.instance.endDrag()`, but this method **doesn't exist**. The correct method is `dragCompleted()`.
|
||||
|
||||
**Root Cause**: Method name was guessed or hallucinated rather than verified against the actual PopupLayer source code.
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - endDrag() doesn't exist
|
||||
const handleDrop = () => {
|
||||
// ... do drop operation
|
||||
PopupLayer.instance.endDrag(); // TypeError!
|
||||
};
|
||||
```
|
||||
|
||||
**The Solution**:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Use dragCompleted()
|
||||
const handleDrop = () => {
|
||||
// ... do drop operation
|
||||
PopupLayer.instance.dragCompleted(); // Works!
|
||||
};
|
||||
```
|
||||
|
||||
**PopupLayer Drag API Reference** (from `popuplayer.js`):
|
||||
|
||||
```javascript
|
||||
// Start drag with label that follows cursor
|
||||
PopupLayer.prototype.startDragging = function (args) {
|
||||
// args: { label: string }
|
||||
// Creates .popup-layer-dragger element
|
||||
};
|
||||
|
||||
// Check if currently dragging
|
||||
PopupLayer.prototype.isDragging = function () {
|
||||
return this.dragItem !== undefined;
|
||||
};
|
||||
|
||||
// Show cursor feedback during drag
|
||||
PopupLayer.prototype.indicateDropType = function (droptype) {
|
||||
// droptype: 'move' | 'copy' | 'none'
|
||||
};
|
||||
|
||||
// ✅ CORRECT: Complete the drag operation
|
||||
PopupLayer.prototype.dragCompleted = function () {
|
||||
this.$('.popup-layer-dragger').css({ opacity: '0' });
|
||||
this.dragItem = undefined;
|
||||
};
|
||||
```
|
||||
|
||||
**Critical Rule**: **Always verify method names against actual source code. Never guess or assume API method names.**
|
||||
|
||||
**Why This Matters**:
|
||||
|
||||
- Silent TypeErrors at runtime
|
||||
- Method names like `endDrag` vs `dragCompleted` are easy to confuse
|
||||
- Legacy JavaScript codebase has no TypeScript definitions to catch this
|
||||
|
||||
**Detection**: If you get `X is not a function` error on PopupLayer, check `popuplayer.js` for the actual method name.
|
||||
|
||||
**Location**:
|
||||
|
||||
- PopupLayer source: `packages/noodl-editor/src/editor/src/views/popuplayer/popuplayer.js`
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts`
|
||||
|
||||
**Keywords**: PopupLayer, dragCompleted, endDrag, TypeError, drag-and-drop, method name, API
|
||||
|
||||
---
|
||||
|
||||
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