# 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) => { 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
``` **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
``` **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
``` **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
{/* Tree items call e.stopPropagation() on valid drops */} {/* If no item stops propagation, this handler catches it */}
; ``` **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 ---