14 KiB
Technical Notes: EventDispatcher + React Investigation
Discovery Context
Task: TASK-004B ComponentsPanel React Migration, Phase 5 (Inline Rename)
Date: 2025-12-22
Discovered by: Debugging why rename UI wasn't updating after successful renames
Detailed Timeline of Discovery
Initial Problem
User renamed a component/folder in ComponentsPanel. The rename logic executed successfully:
performRename()returnedtrue- ProjectModel showed the new name
- Project file saved to disk
- No errors in console
BUT: The UI didn't update to show the new name. The tree still displayed the old name until manual refresh.
Investigation Steps
Step 1: Added Debug Logging
Added console.logs throughout the callback chain:
// In RenameInput.tsx
const handleConfirm = () => {
console.log('🎯 RenameInput: Confirming rename');
onConfirm(value);
};
// In ComponentsPanelReact.tsx
onConfirm={(newName) => {
console.log('📝 ComponentsPanelReact: Rename confirmed', { newName });
const success = performRename(renamingItem, newName);
console.log('✅ ComponentsPanelReact: Rename result:', success);
}}
// In useComponentActions.ts
export function performRename(...) {
console.log('🔧 performRename: Starting', { item, newName });
// ...
console.log('✅ performRename: Success!');
return true;
}
Result: All callbacks fired, logic worked, but UI didn't update.
Step 2: Checked Event Subscription
The useComponentsPanel hook had event subscription code:
useEffect(() => {
const handleUpdate = (eventName: string) => {
console.log('🔔 useComponentsPanel: Event received:', eventName);
setUpdateCounter((c) => c + 1);
};
const listener = { handleUpdate };
ProjectModel.instance.on('componentAdded', () => handleUpdate('componentAdded'), listener);
ProjectModel.instance.on('componentRemoved', () => handleUpdate('componentRemoved'), listener);
ProjectModel.instance.on('componentRenamed', () => handleUpdate('componentRenamed'), listener);
console.log('✅ useComponentsPanel: Event listeners registered');
return () => {
console.log('🧹 useComponentsPanel: Cleaning up event listeners');
ProjectModel.instance.off('componentAdded', listener);
ProjectModel.instance.off('componentRemoved', listener);
ProjectModel.instance.off('componentRenamed', listener);
};
}, []);
Expected: "🔔 useComponentsPanel: Event received: componentRenamed" log after rename
Actual: NOTHING. No event reception logs at all.
Step 3: Verified Event Emission
Added logging to ProjectModel.renameComponent():
renameComponent(component, newName) {
// ... do the rename ...
console.log('📢 ProjectModel: Emitting componentRenamed event');
this.notifyListeners('componentRenamed', { component, oldName, newName });
}
Result: Event WAS being emitted! The emit log appeared, but the React hook never received it.
Step 4: Tried Different Subscription Patterns
Attempted various subscription patterns to see if any worked:
Pattern A: Direct function
ProjectModel.instance.on('componentRenamed', () => {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
});
Result: ❌ No event received
Pattern B: Named function
function handleRenamed() {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
}
ProjectModel.instance.on('componentRenamed', handleRenamed);
Result: ❌ No event received
Pattern C: With useCallback
const handleRenamed = useCallback(() => {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
}, []);
ProjectModel.instance.on('componentRenamed', handleRenamed);
Result: ❌ No event received
Pattern D: Without context object
ProjectModel.instance.on('componentRenamed', () => {
console.log('Event received!');
});
// No third parameter (context object)
Result: ❌ No event received
Pattern E: With useRef for stable reference
const listenerRef = useRef({ handleUpdate });
ProjectModel.instance.on('componentRenamed', listenerRef.current.handleUpdate, listenerRef.current);
Result: ❌ No event received
Step 5: Checked Legacy jQuery Views
Found that the old ComponentsPanel (jQuery-based View) subscribed to the same events:
// In componentspanel/index.tsx (legacy)
this.projectModel.on('componentRenamed', this.onComponentRenamed, this);
Question: Does this work in the legacy View?
Answer: YES! Legacy Views receive events perfectly fine.
This proved:
- The events ARE being emitted correctly
- The EventDispatcher itself works
- But something about React hooks breaks the subscription
Conclusion: Fundamental Incompatibility
After exhaustive testing, the conclusion is clear:
EventDispatcher's pub/sub pattern does NOT work with React hooks.
Even though:
- ✅ Events are emitted (verified with logs)
- ✅ Subscriptions are registered (no errors)
- ✅ Code looks correct
- ✅ Works fine in legacy jQuery Views
The events simply never reach React hook callbacks. This appears to be a fundamental architectural incompatibility.
Workaround Implementation
Since event subscription doesn't work, implemented manual refresh callback pattern:
Step 1: Add forceRefresh Function
In useComponentsPanel.ts:
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
console.log('🔄 Manual refresh triggered');
setUpdateCounter((c) => c + 1);
}, []);
return {
// ... other exports
forceRefresh
};
Step 2: Add onSuccess Parameter
In useComponentActions.ts:
export function performRename(
item: TreeItem,
newName: string,
onSuccess?: () => void // NEW: Success callback
): boolean {
// ... do the rename ...
if (success && onSuccess) {
console.log('✅ Calling onSuccess callback');
onSuccess();
}
return success;
}
Step 3: Wire Through Component
In ComponentsPanelReact.tsx:
const success = performRename(renamingItem, renameValue, () => {
console.log('✅ Rename success callback - calling forceRefresh');
forceRefresh();
});
Step 4: Use Counter as Dependency
In useComponentsPanel.ts:
const treeData = useMemo(() => {
console.log('🔄 Rebuilding tree (updateCounter:', updateCounter, ')');
return buildTree(ProjectModel.instance);
}, [updateCounter]); // Re-build when counter changes
Bug Found: Missing Callback in Folder Rename
The folder rename branch didn't call onSuccess():
// BEFORE (bug):
if (item.type === 'folder') {
const undoGroup = new UndoGroup();
// ... rename logic ...
undoGroup.do();
return true; // ❌ Didn't call onSuccess!
}
// AFTER (fixed):
if (item.type === 'folder') {
const undoGroup = new UndoGroup();
// ... rename logic ...
undoGroup.do();
// Call success callback to trigger UI refresh
if (onSuccess) {
onSuccess();
}
return true; // ✅ Now triggers refresh
}
This bug meant folder renames didn't update the UI, but component renames did.
EventDispatcher Implementation Details
From examining EventDispatcher.ts:
How Listeners Are Stored
class EventDispatcher {
private listeners: Map<string, Array<{ callback: Function; context: any }>>;
on(event: string, callback: Function, context?: any) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push({ callback, context });
}
off(event: string, context?: any) {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
// Remove listeners matching the context object
this.listeners.set(
event,
eventListeners.filter((l) => l.context !== context)
);
}
}
How Events Are Emitted
notifyListeners(event: string, data?: any) {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
// Call each listener
for (const listener of eventListeners) {
try {
listener.callback.call(listener.context, data);
} catch (e) {
console.error('Error in event listener:', e);
}
}
}
Potential Issues with React
-
Context Object Matching:
off()uses strict equality (===) to match context objects- React's useEffect cleanup may not have the same reference
- Could prevent cleanup, leaving stale listeners
-
Callback Invocation:
- Uses
.call(listener.context, data)to invoke callbacks - If context is wrong,
thisbinding might break - React doesn't rely on
this, so this shouldn't matter...
- Uses
-
Timing:
- Events are emitted synchronously
- React state updates are asynchronous
- But setState in callbacks should work...
Mystery: Why don't the callbacks get invoked at all? The listeners should still be in the array, even if cleanup is broken.
Hypotheses for Root Cause
Hypothesis 1: React StrictMode Double-Invocation
React StrictMode (enabled in development) runs effects twice:
- Mount → unmount → mount
This could:
- Register listener on first mount
- Remove listener on first unmount (wrong context?)
- Register listener again on second mount
- But now the old listener is gone?
Test needed: Try with StrictMode disabled
Hypothesis 2: Context Object Reference Lost
const listener = { handleUpdate };
ProjectModel.instance.on('event', handler, listener);
// Later in cleanup:
ProjectModel.instance.off('event', listener);
If the cleanup runs in a different closure, listener might be a new object, causing the filter in off() to not find the original listener.
But this would ACCUMULATE listeners, not prevent them from firing...
Hypothesis 3: EventDispatcher Requires Legacy Context
EventDispatcher might have hidden dependencies on jQuery View infrastructure:
- Maybe it checks for specific properties on the context object?
- Maybe it integrates with View lifecycle somehow?
- Maybe there's initialization that React doesn't do?
Test needed: Deep dive into EventDispatcher implementation
Hypothesis 4: React Rendering Phase Detection
React might be detecting that state updates are happening during render phase and silently blocking them. But our callbacks are triggered by user actions (renames), not during render...
Comparison with Working jQuery Views
Legacy Views use EventDispatcher successfully:
class ComponentsPanel extends View {
init() {
this.projectModel = ProjectModel.instance;
this.projectModel.on('componentRenamed', this.onComponentRenamed, this);
}
onComponentRenamed() {
this.render(); // Just re-render the whole view
}
dispose() {
this.projectModel.off('componentRenamed', this);
}
}
Key differences:
- Views have explicit
init()anddispose()lifecycle - Context object is
this(the View instance), a stable reference - Views use instance methods, not closures
- No dependency arrays or React lifecycle complexity
Why it works:
- The View instance is long-lived and stable
- Context object reference never changes
- Simple, predictable lifecycle
Why React is different:
- Functional components re-execute on every render
- Closures capture different variables each render
- useEffect cleanup might not match subscription
- No stable
thisreference
Next Steps for Investigation
-
Create minimal reproduction:
- Simplest EventDispatcher + React hook
- Isolate the problem
- Add extensive logging
-
Test in isolation:
- React class component (has stable
this) - Without StrictMode
- Without other React features
- React class component (has stable
-
Examine EventDispatcher internals:
- Add logging to every method
- Trace listener registration and invocation
- Check what's in the listeners array
-
Explore solutions:
- Can EventDispatcher be fixed?
- Should we migrate to modern state management?
- Is a React bridge possible?
Workaround Pattern for Other Uses
If other React components need to react to ProjectModel changes, use this pattern:
// 1. In hook, provide manual refresh
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// 2. Export forceRefresh
return { forceRefresh, /* other exports */ };
// 3. In action functions, accept onSuccess callback
function performAction(data: any, onSuccess?: () => void) {
// ... do the action ...
if (success && onSuccess) {
onSuccess();
}
}
// 4. In component, wire them together
performAction(data, () => {
forceRefresh();
});
// 5. Use updateCounter as dependency
const derivedData = useMemo(() => {
return computeData();
}, [updateCounter]);
Critical: Call onSuccess() in ALL code paths (success, different branches, etc.)
Files Changed During Discovery
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts- Added forceRefreshpackages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts- Added onSuccess callbackpackages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx- Wired forceRefresh throughdev-docs/reference/LEARNINGS.md- Documented the discoverydev-docs/tasks/phase-2/TASK-008-eventdispatcher-react-investigation/- Created this investigation task
Open Questions
-
Why don't the callbacks get invoked AT ALL? Even with broken cleanup, they should be in the listeners array...
-
Are there ANY React components successfully using EventDispatcher? (Need to search codebase)
-
Is this specific to ProjectModel, or do ALL EventDispatcher subclasses have this issue?
-
Does it work with React class components? (They have stable
thisreference) -
What happens if we add extensive logging to EventDispatcher itself?
-
Is there something special about how ProjectModel emits events?
-
Could this be related to the Proxy pattern used in some models?
References
- EventDispatcher:
packages/noodl-editor/src/editor/src/shared/utils/EventDispatcher.ts - ProjectModel:
packages/noodl-editor/src/editor/src/models/projectmodel.ts - Working example (legacy View):
packages/noodl-editor/src/editor/src/views/panels/componentspanel/index.tsx - Workaround implementation:
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/