Files
OpenNoodl/dev-docs/reference/LEARNINGS.md
2025-12-31 09:34:27 +01:00

1299 lines
43 KiB
Markdown

# Project Learnings
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
**Context**: During TASK-004B (ComponentsPanel React migration), discovered that `componentRenamed` events from ProjectModel weren't triggering UI updates in React components.
**Root Cause**: Array reference instability causing useEffect to constantly re-subscribe/unsubscribe.
**Discovery**:
```typescript
// ❌ BAD - Creates new array on every render
useEventListener(
ProjectModel.instance,
['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'],
callback
);
// ✅ GOOD - Stable reference prevents re-subscription
const PROJECT_EVENTS = ['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'];
useEventListener(ProjectModel.instance, PROJECT_EVENTS, callback);
```
**Location**:
- `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts`
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts`
**Keywords**: EventDispatcher, React hooks, useEffect, event subscription, array reference, re-render
---
## Hot Reload Issues with React Hooks (Dec 2025)
**Context**: Code changes to React hooks not taking effect despite webpack hot reload.
**Discovery**: React hooks sometimes require a **hard browser refresh** or **dev server restart** to pick up changes, especially:
- Changes to `useEffect` dependencies
- Changes to custom hooks
- Changes to event subscription logic
**Solution**:
1. Try hard refresh first: `Cmd+Shift+R` (Mac) or `Ctrl+Shift+R` (Windows)
2. If that fails, restart dev server: Stop (Ctrl+C) and `npm run dev`
3. Clear browser cache if issues persist
**Location**: Affects all React hook development
**Keywords**: hot reload, React hooks, webpack, dev server, browser cache
---
## Webpack 5 Persistent Caching Issues (Dec 2025)
### Problem: Code Changes Not Loading Despite Dev Server Restart
**Context**: During TASK-004B, discovered that TypeScript source file changes weren't appearing in the running Electron app, even after multiple `npm run dev` restarts and cache clearing attempts.
**Root Cause**: Webpack 5 enables aggressive persistent caching by default:
- **Primary cache**: `packages/noodl-editor/node_modules/.cache`
- **Electron cache**: `~/Library/Application Support/Electron` (macOS)
- **App cache**: `~/Library/Application Support/OpenNoodl` (macOS)
**Discovery**: Standard cache clearing may not be sufficient. The caches can persist across:
- Dev server restarts
- Electron restarts
- Multiple rapid development iterations
**Solution**:
```bash
# Kill any running processes first
killall node
killall Electron
# Clear all caches
cd packages/noodl-editor
rm -rf node_modules/.cache
rm -rf ~/Library/Application\ Support/Electron
rm -rf ~/Library/Application\ Support/OpenNoodl
# Start fresh
npm run dev
```
**Best Practice**: When debugging webpack/compilation issues, add module-level console.log markers at the TOP of your files to verify new code is loading:
```typescript
// At top of file
console.log('🔥 MyModule.ts LOADED - Version 2.0');
```
If you don't see this marker in the console, your changes aren't loading - it's a cache/build issue, not a code issue.
**Location**: Affects all webpack-compiled code in `packages/noodl-editor/`
**Keywords**: webpack, cache, persistent caching, hot reload, dev server, Electron
---
## React 19 useEffect with Array Dependencies (Dec 2025)
### Problem: useEffect with Array Dependency Never Executes
**Context**: During TASK-004B, discovered that passing an array as a single dependency to useEffect prevents the effect from ever running.
**Root Cause**: React 19's `Object.is()` comparison for dependencies doesn't work correctly when an array is passed as a single dependency item.
**Discovery**:
```typescript
// ❌ BROKEN - useEffect NEVER runs
const eventNames = ['event1', 'event2', 'event3'];
useEffect(() => {
console.log('This never prints!');
}, [dispatcher, eventNames]); // eventNames is an array reference
// ✅ CORRECT - Spread array into individual dependencies
const eventNames = ['event1', 'event2', 'event3'];
useEffect(() => {
console.log('This runs correctly');
}, [dispatcher, ...eventNames]); // Spreads to: [dispatcher, 'event1', 'event2', 'event3']
// ✅ ALSO CORRECT - Use stable array reference outside component
const EVENT_NAMES = ['event1', 'event2', 'event3']; // Outside component
function MyComponent() {
useEffect(() => {
// Works because EVENT_NAMES reference is stable
}, [dispatcher, ...EVENT_NAMES]);
}
```
**Critical Rule**: **Never pass an array as a dependency to useEffect. Always spread it.**
**Location**: Affects `useEventListener` hook and any custom hooks with array dependencies
**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: Runtime Node Creation - The Unholy Trinity of Silent Failures (Dec 2025)
### The HTTP Node Debugging Saga: 3+ Hours of Silent Failures
**Context**: Creating an HTTP node (httpnode.js) as a modern, no-code-friendly alternative to the script-based REST node. Everything looked correct but nothing worked. Signals never fired, config values never set, and no errors anywhere.
**The Problems Found** (each took significant debugging time):
#### Problem 1: Signal Input Using `set` Instead of `valueChangedToTrue`
```javascript
// ❌ WHAT I WROTE (never triggers)
inputs: {
fetch: {
type: 'signal',
set: function(value) {
this.scheduleFetch(); // ☠️ Never called
}
}
}
// ✅ WHAT IT SHOULD BE
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() {
this.scheduleFetch(); // ✓ Works!
}
}
}
```
**Why**: Signals use `EdgeTriggeredInput.createSetter()` which wraps the callback and only calls `valueChangedToTrue` when value transitions from falsy to truthy. The `set` callback is never used.
#### Problem 2: Custom `setInputValue` Overriding Base Method
```javascript
// ❌ WHAT I WROTE (breaks ALL inputs including signals)
prototypeExtensions: {
setInputValue: function(name, value) {
this._internal.inputValues[name] = value; // ☠️ Overrides Node.prototype.setInputValue
}
}
// ✅ WHAT IT SHOULD BE
prototypeExtensions: {
_storeInputValue: function(name, value) { // Different name!
this._internal.inputValues[name] = value;
}
}
```
**Why**: `prototypeExtensions` methods are merged into node prototype. Defining `setInputValue` completely replaces the base implementation, which handles signal detection, input.set() calls, and event emission.
#### Problem 3: Dynamic Ports Replacing Static Ports
```javascript
// ❌ WHAT I WROTE (static ports disappear)
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Only add dynamic header/query param ports...
if (parameters.headers) { /* add header ports */ }
editorConnection.sendDynamicPorts(nodeId, ports); // Static inputs GONE!
}
// ✅ WHAT IT SHOULD BE
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
// Re-add ALL static inputs
{ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' },
{ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' },
{ name: 'cancel', displayName: 'Cancel', type: 'signal', plug: 'input', group: 'Actions' },
// Then dynamic ports...
];
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**Why**: `sendDynamicPorts` REPLACES all ports, not merges. The `inputs` object in node definition is only for default setup - once dynamic ports are sent, they're the only ports.
#### Problem 4: Config Inputs (StringList/Enum) Need Explicit Registration
```javascript
// ❌ MISSING (config values never reach setters)
// StringList inputs like "headers", "queryParams" appear in editor but their
// values never reach the node because there's no registered input handler
// ✅ WHAT IT NEEDS
registerInputIfNeeded: function(name) {
if (this.hasInput(name)) return;
const configSetters = {
'method': this.setMethod.bind(this),
'headers': this.setHeaders.bind(this),
'queryParams': this.setQueryParams.bind(this),
'bodyType': this.setBodyType.bind(this),
// ... all config inputs
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Dynamic inputs (header-X, query-Y, etc.)
if (name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
```
**Why**: Inputs defined in the `inputs` object get registered automatically. Dynamic ports don't - they need `registerInputIfNeeded` to create runtime handlers.
**Why This Was So Hard to Debug**:
1. **No errors** - Everything appeared to work, logs said "success", but nothing happened
2. **Partial success** - Some things worked (node appeared in palette) making it seem close
3. **Multiple bugs** - Each fix revealed the next bug, each taking time to diagnose
4. **No TypeScript** - Runtime code is JS, no compile-time checking
5. **Unfamiliar patterns** - `valueChangedToTrue`, `registerInputIfNeeded`, etc. aren't obvious
**Time Lost**: 3+ hours debugging what should have been a straightforward node
**Prevention**:
- Created `dev-docs/reference/LEARNINGS-NODE-CREATION.md` with Critical Gotchas section
- Added Node Creation Checklist to `.clinerules` Section 14
- These gotchas are now documented with THE BUG / WHY IT BREAKS / THE FIX patterns
**Location**:
- `packages/noodl-runtime/src/nodes/std-library/data/httpnode.js` (fixed)
- `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (new documentation)
**Keywords**: node creation, signal, valueChangedToTrue, setInputValue, prototypeExtensions, sendDynamicPorts, registerInputIfNeeded, stringlist, enum, config inputs, HTTP node, runtime
---
## 🔥 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
---
## NoodlRuntime.instance.getMetaData() Pattern for Project Data (Dec 2025)
### How Runtime Nodes Access Project Metadata
**Context**: BYOB Query Data node needed to access backend services configuration (URLs, tokens, schema) from runtime code.
**The Pattern**: Use `NoodlRuntime.instance.getMetaData(key)` to access project metadata stored in graphModel.
**Working Example** (from byob-query-data.js):
```javascript
const NoodlRuntime = require('../../../../noodl-runtime');
resolveBackend: function() {
// Get metadata - same pattern as cloudstore.js uses for cloudservices
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Query Data] No backend services metadata found');
console.log('[BYOB Query Data] Available metadata keys:',
Object.keys(NoodlRuntime.instance.metadata || {}));
return null;
}
// Access the data
const backends = backendServices.backends || [];
const activeBackendId = backendServices.activeBackendId;
// Find and use the backend config...
}
```
**Reference Implementation** (from `src/api/cloudstore.js`):
```javascript
const NoodlRuntime = require('../../noodl-runtime');
// Access cloud services config
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
```
**How It Works**:
- `NoodlRuntime.prototype.getMetaData(key)` delegates to `this.graphModel.getMetaData(key)`
- Metadata is stored in the project file and loaded into graphModel
- Editor components set metadata via `graphModel.setMetaData(key, value)`
**Available Metadata Keys** (varies by project):
- `cloudservices` - Parse/Firebase cloud settings
- `backendServices` - BYOB backend configurations
- Project-specific settings
**Location**:
- NoodlRuntime API: `packages/noodl-runtime/noodl-runtime.js` (line 299)
- Pattern reference: `packages/noodl-runtime/src/api/cloudstore.js`
- BYOB usage: `packages/noodl-runtime/src/nodes/std-library/data/byob-query-data.js`
**Keywords**: NoodlRuntime, getMetaData, project metadata, runtime, backend config, cloudservices
---
## 🔴 Runtime Nodes Must Be in coreNodes Index (Dec 2025)
### Problem: Node Module Loads But Doesn't Appear in Node Picker
**Context**: TASK-002 BYOB Data Nodes - Created `byob-query-data.js`, registered in `register-nodes.js`, console showed "Module loaded" but node never appeared in Node Picker.
**Root Cause**: Runtime nodes need to be registered in THREE places:
1. ✅ Node file created (`noodl-runtime/src/nodes/std-library/data/byob-query-data.js`)
2. ✅ Registered in `register-nodes.js` via `require()`
3.**MISSING** - Added to `coreNodes` index in `nodelibraryexport.js`
**The Hidden Requirement**:
```javascript
// In nodelibraryexport.js, the coreNodes array determines Node Picker organization
const coreNodes = [
{
name: 'Read & Write Data',
subCategories: [
{
name: 'External Data',
items: ['net.noodl.HTTP', 'REST2'] // HTTP appears because it's HERE
}
// Node not in this array = not in Node Picker!
]
}
];
```
**The Fix**:
```javascript
// Add node to appropriate category in coreNodes
{
name: 'BYOB Data',
items: ['noodl.byob.QueryData'] // Now appears in Node Picker!
}
```
**Why This Is Easy to Miss**:
- Module loads fine (console log appears)
- No errors anywhere
- Node IS registered in `nodeRegister._constructors`
- Node IS in `nodetypes` array exported to editor
- But Node Picker uses `coreNodes` index for organization
**Critical Rule**: After creating a node, ALWAYS add it to `nodelibraryexport.js` coreNodes array.
**Location**:
- `packages/noodl-runtime/src/nodelibraryexport.js` (coreNodes array)
- Documented in: `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (Step 3)
**Keywords**: node picker, coreNodes, nodelibraryexport, runtime node, silent failure, node not appearing
---
## 🔥 CRITICAL: LocalProjectsModel.loadProject() Doesn't Navigate to Editor (Dec 2025)
### The Silent Success: Projects Load But Don't Open
**Context**: Phase 3 Launcher integration - Implemented all project management buttons (Open Project, Launch Project, Create Project). LocalProjectsModel.loadProject() succeeded and returned a ProjectModel, but the editor never opened.
**The Problem**: `LocalProjectsModel.loadProject()` **only loads the project into memory**. It does NOT navigate to the editor. You must explicitly call the router after loading.
**Discovery Timeline**:
```
🔵 [handleOpenProject] Starting...
🔵 [handleOpenProject] Selected folder: /Users/.../MAU chatbot
🔵 [handleOpenProject] Calling openProjectFromFolder...
🔵 [handleOpenProject] Got project: ProjectModel
🔵 [handleOpenProject] Loading project...
🔵 [handleOpenProject] Project loaded: ProjectModel
✅ [handleOpenProject] Success! Project should open now
// But no project opens 🤔
```
**Root Cause**: Missing router navigation call after successful project load.
**The Broken Pattern**:
```typescript
// ❌ WRONG - Project loads but editor never opens
const handleOpenProject = async () => {
const direntry = await filesystem.openDialog({ ... });
const project = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
const projectEntry = LocalProjectsModel.instance.getProjects().find(p => p.id === project.id);
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
// ☠️ Returns ProjectModel but nothing happens
// User stays on dashboard, confused
};
```
**The Solution**:
```typescript
// ✅ RIGHT - Navigate to editor after loading
const handleOpenProject = useCallback(async () => {
const direntry = await filesystem.openDialog({ ... });
const project = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
const projectEntry = LocalProjectsModel.instance.getProjects().find(p => p.id === project.id);
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
if (loaded) {
// Navigate to editor with loaded project
props.route.router.route({ to: 'editor', project: loaded });
}
}, [props.route]);
```
**Why This Pattern Exists**:
1. `loadProject()` is a data operation - it loads project files, initializes ProjectModel, sets up modules
2. Router navigation is a separate concern - handled by the Router component
3. In Phase 3, `ProjectsPage` (React) must explicitly trigger navigation
4. In legacy code, this was handled by imperative routing in Backbone views
**The Router Navigation Pattern** (Phase 3):
```typescript
// Access router through props.route.router
interface ProjectsPageProps extends IRouteProps {
from: TSFixme;
// route.router is the Router instance
}
// Navigate with project data
props.route.router.route({
to: 'editor', // Target route
project: loaded // ProjectModel instance
});
// Navigate without project (back to dashboard)
props.route.router.route({
to: 'projects',
from: 'editor'
});
```
**Applies to All Project Opening Scenarios**:
1. **Create Project**: After `newProject()` callback → navigate to editor
2. **Open Project**: After `openProjectFromFolder()` + `loadProject()` → navigate to editor
3. **Launch Project**: After `loadProject()` from list → navigate to editor
**Critical Rule**: **`loadProject()` only loads data. Always call `props.route.router.route()` to actually open the editor.**
**Location**:
- Fixed in: `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx`
- Router source: `packages/noodl-editor/src/editor/src/router.tsx`
- Phase 3 context: DASH-001 Tabbed Navigation integration
**Detection**: If you see success logs but the editor doesn't open, you're missing the router navigation call.
**Keywords**: LocalProjectsModel, loadProject, router, navigation, Phase 3, Launcher, ProjectsPage, silent success, openProjectFromFolder
---