4.2 KiB
TASK-008: EventDispatcher + React Hooks Investigation - CHANGELOG
2025-12-22 - Solution Implemented ✅
Root Cause Identified
The Problem: EventDispatcher's context-object-based cleanup pattern is incompatible with React's closure-based lifecycle.
Technical Details:
- EventDispatcher uses
on(event, listener, group)andoff(group) - React's useEffect creates new closures on every render
- The
groupobject reference used in cleanup doesn't match the one from subscription - This prevents proper cleanup AND somehow blocks event delivery entirely
Solution: useEventListener Hook
Created a React-friendly hook at packages/noodl-editor/src/editor/src/hooks/useEventListener.ts that:
- Prevents Stale Closures: Uses
useRefto store callback, updated on every render - Stable Group Reference: Creates unique group object per subscription
- Automatic Cleanup: Returns cleanup function that React can properly invoke
- Flexible Types: Accepts EventDispatcher, Model subclasses, or any IEventEmitter
Changes Made
1. Created useEventListener Hook
File: packages/noodl-editor/src/editor/src/hooks/useEventListener.ts
- Main hook:
useEventListener(dispatcher, eventName, callback, deps?) - Convenience wrapper:
useEventListenerMultiple(dispatcher, eventNames, callback, deps?) - Supports both single events and arrays of events
- Optional dependency array for conditional re-subscription
2. Updated ComponentsPanel
Files:
hooks/useComponentsPanel.ts: Replaced manual subscription withuseEventListenerComponentsPanelReact.tsx: RemovedforceRefreshworkaroundhooks/useComponentActions.ts: RemovedonSuccesscallback parameter
Before (manual workaround):
const [updateCounter, setUpdateCounter] = useState(0);
useEffect(() => {
const listener = { handleUpdate };
ProjectModel.instance.on('componentRenamed', () => handleUpdate('componentRenamed'), listener);
return () => ProjectModel.instance.off(listener);
}, []);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// In actions: performRename(item, name, () => forceRefresh());
After (clean solution):
useEventListener(
ProjectModel.instance,
['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'],
() => {
setUpdateCounter((c) => c + 1);
}
);
// In actions: performRename(item, name); // Events handled automatically!
Benefits
✅ No More Manual Callbacks: Events are properly received automatically ✅ No Tech Debt: Removed workaround pattern from ComponentsPanel ✅ Reusable Solution: Hook works for any EventDispatcher-based model ✅ Type Safe: Proper TypeScript types with interface matching ✅ Scalable: Can be used by all 56+ React components that need event subscriptions
Testing
Verified that:
- ✅ Component rename updates UI immediately
- ✅ Folder rename updates UI immediately
- ✅ No stale closure issues
- ✅ Proper cleanup on unmount
- ✅ TypeScript compilation successful
Impact
Immediate:
- ComponentsPanel now works correctly without workarounds
- Sets pattern for future React migrations
Future:
- 56+ existing React component subscriptions can be migrated to use this hook
- Major architectural improvement for jQuery View → React migrations
- Removes blocker for migrating more panels to React
Files Modified
-
Created:
packages/noodl-editor/src/editor/src/hooks/useEventListener.ts
-
Updated:
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.tspackages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsxpackages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts
Next Steps
- ✅ Document pattern in LEARNINGS.md
- ⬜ Create usage guide for other React components
- ⬜ Consider migrating other components to use useEventListener
- ⬜ Evaluate long-term migration to modern state management (Zustand/Redux)
Investigation Summary
Time Spent: ~2 hours Status: ✅ RESOLVED Solution Type: React Bridge Hook (Solution 2 from POTENTIAL-SOLUTIONS.md)