Files
OpenNoodl/dev-docs/tasks/phase-2/TASK-008-eventdispatcher-react-investigation/NOTES.md
2025-12-23 09:39:33 +01:00

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() returned true
  • 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

  1. 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
  2. Callback Invocation:

    • Uses .call(listener.context, data) to invoke callbacks
    • If context is wrong, this binding might break
    • React doesn't rely on this, so this shouldn't matter...
  3. 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:

  1. 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() and dispose() 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 this reference

Next Steps for Investigation

  1. Create minimal reproduction:

    • Simplest EventDispatcher + React hook
    • Isolate the problem
    • Add extensive logging
  2. Test in isolation:

    • React class component (has stable this)
    • Without StrictMode
    • Without other React features
  3. Examine EventDispatcher internals:

    • Add logging to every method
    • Trace listener registration and invocation
    • Check what's in the listeners array
  4. 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 forceRefresh
  • packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts - Added onSuccess callback
  • packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx - Wired forceRefresh through
  • dev-docs/reference/LEARNINGS.md - Documented the discovery
  • dev-docs/tasks/phase-2/TASK-008-eventdispatcher-react-investigation/ - Created this investigation task

Open Questions

  1. Why don't the callbacks get invoked AT ALL? Even with broken cleanup, they should be in the listeners array...

  2. Are there ANY React components successfully using EventDispatcher? (Need to search codebase)

  3. Is this specific to ProjectModel, or do ALL EventDispatcher subclasses have this issue?

  4. Does it work with React class components? (They have stable this reference)

  5. What happens if we add extensive logging to EventDispatcher itself?

  6. Is there something special about how ProjectModel emits events?

  7. 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/