31 KiB
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):
// ❌ 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:
// ✅ 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:
// ❌ 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.tspackages/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
useEffectdependencies - Changes to custom hooks
- Changes to event subscription logic
Solution:
- Try hard refresh first:
Cmd+Shift+R(Mac) orCtrl+Shift+R(Windows) - If that fails, restart dev server: Stop (Ctrl+C) and
npm run dev - 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:
# 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:
// 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:
// ❌ 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:
// ❌ 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:
- Component mounts → useEffect runs
ProjectModel.instanceisnull(project not loaded yet)- Early return, no subscription
- Project loads →
ProjectModel.instancegets set - useEffect doesn't re-run (instance not in deps)
- Events fire but nobody's listening 🦗
The Solution:
// ✅ 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.instanceNodeLibrary.instanceWarningsModel.instanceEventDispatcher.instanceUndoQueue.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()incrementsptrtoactions.lengthdo()loops fromptrtoactions.length- But if
ptr === actions.length, the loop never runs!
The Broken Pattern:
// ❌ 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:
// ✅ 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):
// ✅ 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):
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:
// 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:
// ❌ 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:
// ✅ 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:
-
Content format differs:
showPopup()takes direct object:content: popupshowPopout()requires wrapper:content: { el: popup.el }
-
Position values differ:
showPopup()only supports'screen-center'showPopout()supports'bottom'|'top'|'left'|'right'
-
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: trueflag to make modal properly visible with dimmed background
Detection: If popup/popout logs success but doesn't appear, check:
- Are you using the right API method?
- Is the content format correct for that API?
- 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:
-
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
-
Attempt 2: PopupLayer.showPopout() with button ref
- Result: Silent failures despite "success" logs
- Why: Content format issues, API confusion
- Duration: 1+ hours debugging
-
Attempt 3: NewPopupLayer.PopupMenu constructor
- Result: Runtime error "NewPopupLayer.PopupMenu is not a constructor"
- Why: PopupMenu not properly exported/accessible
- Duration: 30 minutes debugging
-
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
-
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:
// ❌ 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:
- User clicks button
- React synthetic event fires
- Event propagates through React and into Electron
- By the time
showContextMenuInPopup()runs, cursor has moved screen.getCursorScreenPoint()gives wrong position- Menu either doesn't appear or appears in wrong location
Why Right-Click Works:
- User right-clicks
- Context menu event fires
- Cursor is still exactly where user clicked
screen.getCursorScreenPoint()gives correct position- Menu appears at cursor location (expected UX)
The Working Pattern:
// 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:
// ❌ 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:
- Never use button clicks to trigger showContextMenuInPopup() - cursor positioning will be wrong
- Right-click context menus are reliable - cursor position is naturally correct
- Legacy PopupLayer/PopupMenu integration with React is fragile - avoid if possible
- When something fails repeatedly with the same pattern, change the approach - "this is the renaming task all over again"
- 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:
// ❌ 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:
// ✅ 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:
getComponents()returns the internal array (same reference)- When component is added, array is mutated with
push() Object.is(oldArray, newArray)returnstrue(same reference)- useMemo thinks nothing changed, skips recalculation
- 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:
-
HTML5 Drag-and-Drop API:
- Uses
draggable="true"attribute - Events:
ondragstart,ondragenter,ondragover,ondragleave,ondrop - Native browser implementation with built-in ghost image
onDropfires when dropping a dragged element
- Uses
-
Mouse-Based Dragging (PopupLayer):
- Uses
onMouseDown,onMouseMove,onMouseUp - Custom implementation that moves a label element with cursor
onDropnever fires - must useonMouseUpto detect drop
- Uses
The Broken Pattern:
// ❌ 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:
// ✅ 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:
- User starts drag via
onMouseDown+onMouseMove(5px threshold) →PopupLayer.startDragging() - User hovers over target →
onMouseEntersetsisDropTarget = true - User releases mouse →
onMouseUpchecksisDropTargetand executes drop e.stopPropagation()prevents event bubbling to parent containers
Root Drop Zone Pattern:
For dropping onto empty space (background), use event bubbling:
// 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:
// ❌ WRONG - endDrag() doesn't exist
const handleDrop = () => {
// ... do drop operation
PopupLayer.instance.endDrag(); // TypeError!
};
The Solution:
// ✅ RIGHT - Use dragCompleted()
const handleDrop = () => {
// ... do drop operation
PopupLayer.instance.dragCompleted(); // Works!
};
PopupLayer Drag API Reference (from popuplayer.js):
// 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
endDragvsdragCompletedare 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