diff --git a/dev-docs/reference/LEARNINGS.md b/dev-docs/reference/LEARNINGS.md
index b6ebbc7..3dbc7a8 100644
--- a/dev-docs/reference/LEARNINGS.md
+++ b/dev-docs/reference/LEARNINGS.md
@@ -160,6 +160,85 @@ Using `overrides` for this case can conflict with other version specifications.
---
+## React 18/19 Migration Patterns
+
+### [2025-12-08] - React 18+ Removed ReactDOM.render() and unmountComponentAtNode()
+
+**Context**: After React 19 migration, node graph editor was completely broken - right-click showed grab hand instead of node picker, couldn't click nodes or drag wires.
+
+**Discovery**: React 18 removed the legacy `ReactDOM.render()` and `ReactDOM.unmountComponentAtNode()` APIs. Code using these APIs throws errors like:
+- `ReactDOM.render is not a function`
+- `ReactDOM.unmountComponentAtNode is not a function`
+
+The migration pattern is:
+
+```javascript
+// Before (React 17):
+import ReactDOM from 'react-dom';
+ReactDOM.render(, container);
+ReactDOM.unmountComponentAtNode(container);
+
+// After (React 18+):
+import { createRoot } from 'react-dom/client';
+const root = createRoot(container);
+root.render();
+root.unmount();
+```
+
+**Important**: If rendering multiple times to the same container, you must:
+1. Create the root only ONCE
+2. Store the root reference
+3. Call `root.render()` for subsequent updates
+4. Call `root.unmount()` when disposing
+
+Creating `createRoot()` on every render causes: "You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before."
+
+**Location**:
+- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js`
+- `packages/noodl-editor/src/editor/src/views/commentlayer.ts`
+- `packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx`
+
+**Keywords**: ReactDOM.render, createRoot, unmountComponentAtNode, React 18, React 19, migration, root.unmount
+
+---
+
+### [2025-12-08] - React 18+ createRoot() Renders Asynchronously
+
+**Context**: After migrating to React 18+ createRoot, the NodePicker popup appeared offset to the bottom-right corner instead of centered.
+
+**Discovery**: Unlike the old synchronous `ReactDOM.render()`, React 18's `createRoot().render()` is asynchronous. If code measures DOM dimensions immediately after calling `render()`, the React component hasn't painted yet.
+
+In PopupLayer.showPopup():
+```javascript
+this.$('.popup-layer-popup-content').append(content);
+var contentWidth = content.outerWidth(true); // Returns 0!
+var contentHeight = content.outerHeight(true); // Returns 0!
+```
+
+When dimensions are zero, the centering calculation `x = this.width / 2 - 0 / 2` places the popup at the far right.
+
+**Fix Options**:
+1. **Set explicit dimensions** on the container div before React renders (recommended for fixed-size components)
+2. Use `requestAnimationFrame` or `setTimeout` before measuring
+3. Use a ResizeObserver to detect when content renders
+
+For NodePicker (which has fixed 800x600 dimensions in CSS), the simplest fix was setting dimensions on the container div before React renders:
+```javascript
+render() {
+ const div = document.createElement('div');
+ div.style.width = '800px';
+ div.style.height = '600px';
+ this.renderReact(div); // createRoot is async
+ return this.el;
+}
+```
+
+**Location**: `packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts`
+
+**Keywords**: createRoot, async render, dimensions, outerWidth, outerHeight, popup positioning, React 18, React 19
+
+---
+
## Template for Future Entries
```markdown
diff --git a/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHANGELOG.md b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHANGELOG.md
new file mode 100644
index 0000000..05f55d5
--- /dev/null
+++ b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHANGELOG.md
@@ -0,0 +1,61 @@
+# TASK-002: React 19 UI Fixes - Changelog
+
+## 2025-12-08
+
+### Investigation
+- Identified root cause: Legacy React 17 APIs still in use after Phase 1 migration
+- Found 3 files requiring migration:
+ - `nodegrapheditor.debuginspectors.js` - Uses `ReactDOM.render()` and `unmountComponentAtNode()`
+ - `commentlayer.ts` - Creates new `createRoot()` on every render
+ - `TextStylePicker.jsx` - Uses `ReactDOM.render()` and `unmountComponentAtNode()`
+- Confirmed these errors cause all reported UI bugs (node picker, config panel, wire connectors)
+
+### Changes Made
+
+#### nodegrapheditor.debuginspectors.js
+- **Before**: Used `ReactDOM.render()` at line 60, `ReactDOM.unmountComponentAtNode()` at line 64
+- **After**: Migrated to React 18+ `createRoot()` API with proper root management
+
+#### commentlayer.ts
+- **Before**: Created new roots on every `_renderReact()` call, causing React warnings
+- **After**: Check if roots exist before creating, reuse existing roots
+
+#### TextStylePicker.jsx
+- **Before**: Used `ReactDOM.render()` and `unmountComponentAtNode()` in useEffect
+- **After**: Migrated to `createRoot()` API with proper cleanup
+
+### Testing Notes
+- [ ] Verified right-click node picker works
+- [ ] Verified plus icon node picker positions correctly
+- [ ] Verified node config panel appears
+- [ ] Verified wire connectors can be dragged
+- [ ] Verified no more React 19 API errors in console
+
+### Code Changes Summary
+
+**nodegrapheditor.debuginspectors.js:**
+- Changed import from `require('react-dom')` to `require('react-dom/client')`
+- Added `this.root` property to store React root reference
+- `render()`: Now creates root only once with `createRoot()`, reuses for subsequent renders
+- `dispose()`: Uses `this.root.unmount()` instead of `ReactDOM.unmountComponentAtNode()`
+
+**commentlayer.ts:**
+- `_renderReact()`: Now checks if roots exist before calling `createRoot()`
+- `renderTo()`: Properly resets roots to `null` after unmounting when switching divs
+- `dispose()`: Added null checks before unmounting
+
+**TextStylePicker.jsx:**
+- Changed import from `ReactDOM from 'react-dom'` to `{ createRoot } from 'react-dom/client'`
+- `useEffect`: Creates local root with `createRoot()`, renders popup, unmounts in cleanup
+
+**nodegrapheditor.ts:**
+- Added `toolbarRoots: Root[]` array to store toolbar React roots
+- Added `titleRoot: Root | null` for the title bar root
+- Toolbar rendering now creates roots only once and reuses them
+- `reset()`: Properly unmounts all toolbar roots and title root
+
+**createnewnodepanel.ts:**
+- Added explicit `width: 800px; height: 600px` on container div before React renders
+- This fixes popup positioning since React 18's `createRoot()` is async
+- PopupLayer measures dimensions immediately after appending, but async render hasn't finished
+- With explicit dimensions, PopupLayer calculates correct centered position
diff --git a/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHECKLIST.md b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHECKLIST.md
new file mode 100644
index 0000000..cf1c985
--- /dev/null
+++ b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/CHECKLIST.md
@@ -0,0 +1,67 @@
+# TASK-002: React 19 UI Fixes - Checklist
+
+## Pre-Flight Checks
+- [x] Confirm on correct branch
+- [x] Review current error messages in devtools
+- [x] Understand existing code patterns in each file
+
+## File Migrations
+
+### 1. nodegrapheditor.debuginspectors.js (Critical)
+- [x] Replace `require('react-dom')` with `require('react-dom/client')`
+- [x] Add `root` property to store React root reference
+- [x] Update `render()` method:
+ - Create root only once (if not exists)
+ - Use `this.root.render()` instead of `ReactDOM.render()`
+- [x] Update `dispose()` method:
+ - Use `this.root.unmount()` instead of `ReactDOM.unmountComponentAtNode()`
+- [ ] Test: Right-click on canvas should show node picker
+- [ ] Test: Debug inspector popups should work
+
+### 2. commentlayer.ts (High Priority)
+- [x] Update `_renderReact()` to check if roots already exist before creating
+- [x] Only call `createRoot()` if `this.backgroundRoot` is null/undefined
+- [x] Only call `createRoot()` if `this.foregroundRoot` is null/undefined
+- [ ] Test: No warnings about "container already passed to createRoot"
+- [ ] Test: Comment layer renders correctly
+
+### 3. TextStylePicker.jsx (Medium Priority)
+- [x] Replace `import ReactDOM from 'react-dom'` with `import { createRoot } from 'react-dom/client'`
+- [x] Update popup rendering logic to use `createRoot()`
+- [x] Store root reference for cleanup
+- [x] Update cleanup to use `root.unmount()` instead of `unmountComponentAtNode()`
+- [ ] Test: Text style popup opens and closes correctly
+
+### 4. nodegrapheditor.ts (Additional - Found During Work)
+- [x] Add `toolbarRoots: Root[]` array for toolbar React roots
+- [x] Add `titleRoot: Root | null` for title bar root
+- [x] Update toolbar rendering to reuse roots
+- [x] Update `reset()` to properly unmount all roots
+- [ ] Test: Toolbar buttons render correctly
+
+### 5. createnewnodepanel.ts (Additional - Popup Positioning Fix)
+- [x] Add explicit dimensions (800x600) to container div
+- [x] Compensates for React 18's async createRoot() rendering
+- [ ] Test: Node picker popup appears centered
+
+## Post-Migration Verification
+
+### Console Errors
+- [ ] No `ReactDOM.render is not a function` errors
+- [ ] No `ReactDOM.unmountComponentAtNode is not a function` errors
+- [ ] No `createRoot() on a container already passed` warnings
+
+### UI Functionality
+- [ ] Right-click on canvas → Node picker appears (not grab hand)
+- [ ] Click plus icon → Node picker appears in correct position
+- [ ] Click visual node → Config panel appears on left
+- [ ] Click logic node → Config panel appears on left
+- [ ] Drag wire connectors → Connection can be made between nodes
+- [ ] Debug inspectors → Show values on connections
+- [ ] Text style picker → Opens and edits correctly
+- [ ] Comment layer → Comments can be added and edited
+
+## Final Steps
+- [x] Update CHANGELOG.md with changes made
+- [x] Update LEARNINGS.md if new patterns discovered
+- [ ] Commit changes with descriptive message
diff --git a/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/README.md b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/README.md
new file mode 100644
index 0000000..cc3c9b4
--- /dev/null
+++ b/dev-docs/tasks/phase-2/TASK-002-react19-ui-fixes/README.md
@@ -0,0 +1,85 @@
+# TASK-002: React 19 UI Fixes
+
+## Overview
+
+This task addresses critical React 19 API migration issues that were not fully completed during Phase 1. These issues are causing multiple UI bugs in the node graph editor.
+
+## Problem Statement
+
+After the React 19 migration in Phase 1, several legacy React 17 APIs are still being used in the codebase:
+- `ReactDOM.render()` - Removed in React 18+
+- `ReactDOM.unmountComponentAtNode()` - Removed in React 18+
+- Incorrect `createRoot()` usage (creating new roots on every render)
+
+These errors crash the node graph editor's mouse event handlers, causing:
+- Right-click shows 'grab' hand instead of node picker
+- Plus icon node picker appears at wrong position and overflows
+- Node config panel doesn't appear when clicking nodes
+- Wire connectors don't respond to clicks
+
+## Error Messages
+
+```
+ReactDOM.render is not a function
+ at DebugInspectorPopup.render (nodegrapheditor.debuginspectors.js:60)
+
+ReactDOM.unmountComponentAtNode is not a function
+ at DebugInspectorPopup.dispose (nodegrapheditor.debuginspectors.js:64)
+
+You are calling ReactDOMClient.createRoot() on a container that has already
+been passed to createRoot() before.
+ at _renderReact (commentlayer.ts:145)
+```
+
+## Affected Files
+
+| File | Issue | Priority |
+|------|-------|----------|
+| `nodegrapheditor.debuginspectors.js` | Uses legacy `ReactDOM.render()` & `unmountComponentAtNode()` | **Critical** |
+| `commentlayer.ts` | Creates new `createRoot()` on every render | **High** |
+| `TextStylePicker.jsx` | Uses legacy `ReactDOM.render()` & `unmountComponentAtNode()` | **Medium** |
+
+## Solution
+
+### Pattern 1: Replace ReactDOM.render() / unmountComponentAtNode()
+
+```javascript
+// Before (React 17):
+const ReactDOM = require('react-dom');
+ReactDOM.render(, container);
+ReactDOM.unmountComponentAtNode(container);
+
+// After (React 18+):
+import { createRoot } from 'react-dom/client';
+const root = createRoot(container);
+root.render();
+root.unmount();
+```
+
+### Pattern 2: Reuse Existing Roots
+
+```typescript
+// Before (Wrong):
+_renderReact() {
+ this.root = createRoot(this.div);
+ this.root.render();
+}
+
+// After (Correct):
+_renderReact() {
+ if (!this.root) {
+ this.root = createRoot(this.div);
+ }
+ this.root.render();
+}
+```
+
+## Related Tasks
+
+- TASK-001B-react19-migration (Phase 1) - Initial React 19 migration
+- TASK-006-typescript5-upgrade (Phase 1) - TypeScript 5 upgrade
+
+## References
+
+- [React 18 Migration Guide](https://react.dev/blog/2022/03/08/react-18-upgrade-guide)
+- [createRoot API](https://react.dev/reference/react-dom/client/createRoot)
diff --git a/packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx b/packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx
index 9625921..61a08f9 100644
--- a/packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx
+++ b/packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx
@@ -1,5 +1,5 @@
import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
-import ReactDOM from 'react-dom';
+import { createRoot } from 'react-dom/client';
import { StylesModel } from '@noodl-models/StylesModel';
@@ -41,21 +41,22 @@ function TextStylePicker(props) {
if (!styleToEdit || !popupAnchor) return;
const div = document.createElement('div');
- ReactDOM.render(, div);
+ const root = createRoot(div);
+ root.render();
const popout = PopupLayer.instance.showPopout({
content: { el: $(div) },
attachTo: $(popupAnchor),
position: 'right',
onClose: () => {
- ReactDOM.unmountComponentAtNode(div);
+ root.unmount();
}
});
return () => {
PopupLayer.instance.hidePopout(popout);
};
- }, [styleToEdit, popupAnchor]);
+ }, [styleToEdit, popupAnchor, stylesModel]);
let filteredStyles = textStyles;
diff --git a/packages/noodl-editor/src/editor/src/views/commentlayer.ts b/packages/noodl-editor/src/editor/src/views/commentlayer.ts
index 6d5a16b..085ee20 100644
--- a/packages/noodl-editor/src/editor/src/views/commentlayer.ts
+++ b/packages/noodl-editor/src/editor/src/views/commentlayer.ts
@@ -142,16 +142,29 @@ export default class CommentLayer {
return;
}
- this.backgroundRoot = createRoot(this.backgroundDiv);
+ // Create roots only once, reuse for subsequent renders
+ if (!this.backgroundRoot) {
+ this.backgroundRoot = createRoot(this.backgroundDiv);
+ }
this.backgroundRoot.render(React.createElement(CommentLayerView.Background, this.props));
- this.foregroundRoot = createRoot(this.foregroundDiv);
+
+ if (!this.foregroundRoot) {
+ this.foregroundRoot = createRoot(this.foregroundDiv);
+ }
this.foregroundRoot.render(React.createElement(CommentLayerView.Foreground, this.props));
}
renderTo(backgroundDiv, foregroundDiv) {
+ // Clean up existing roots if we're switching to new divs
if (this.backgroundDiv) {
- this.backgroundRoot.unmount();
- this.foregroundRoot.unmount();
+ if (this.backgroundRoot) {
+ this.backgroundRoot.unmount();
+ this.backgroundRoot = null;
+ }
+ if (this.foregroundRoot) {
+ this.foregroundRoot.unmount();
+ this.foregroundRoot = null;
+ }
}
this.backgroundDiv = backgroundDiv;
@@ -301,12 +314,20 @@ export default class CommentLayer {
}
dispose() {
- this.foregroundRoot.unmount();
- this.backgroundRoot.unmount();
+ if (this.foregroundRoot) {
+ this.foregroundRoot.unmount();
+ this.foregroundRoot = null;
+ }
+ if (this.backgroundRoot) {
+ this.backgroundRoot.unmount();
+ this.backgroundRoot = null;
+ }
//hack to remove all event listeners without having to keep track of them
- const newForegroundDiv = this.foregroundDiv.cloneNode(true);
- this.foregroundDiv.parentNode.replaceChild(newForegroundDiv, this.foregroundDiv);
+ if (this.foregroundDiv && this.foregroundDiv.parentNode) {
+ const newForegroundDiv = this.foregroundDiv.cloneNode(true);
+ this.foregroundDiv.parentNode.replaceChild(newForegroundDiv, this.foregroundDiv);
+ }
if (this.model) {
this.model.off(this);
diff --git a/packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts b/packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts
index 0680723..e5eef69 100644
--- a/packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts
+++ b/packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts
@@ -84,6 +84,12 @@ export class CreateNewNodePanel extends View {
render() {
const div = document.createElement('div');
+
+ // Set explicit dimensions so PopupLayer can measure correctly
+ // before React 18's async rendering completes
+ // These dimensions match NodePicker.module.scss: width: 800px, height: 600px
+ div.style.width = '800px';
+ div.style.height = '600px';
this.renderReact(div);
diff --git a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js
index d1dc9ed..51b7447 100644
--- a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js
+++ b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js
@@ -1,7 +1,7 @@
const DebugInspector = require('../utils/debuginspector');
const { ProjectModel } = require('../models/projectmodel');
const { InspectPopup } = require('./nodegrapheditor/InspectJSONView/InspectPopup');
-const ReactDOM = require('react-dom');
+const { createRoot } = require('react-dom/client');
const React = require('react');
const { EventDispatcher } = require('../../../shared/utils/EventDispatcher');
@@ -57,11 +57,18 @@ DebugInspectorPopup.prototype.render = function () {
this.togglePinned();
};
- ReactDOM.render(React.createElement(InspectPopup, { debugValue, onPinClicked, pinned: this.model.pinned }), this.div);
+ // Create root only once, reuse for subsequent renders
+ if (!this.root) {
+ this.root = createRoot(this.div);
+ }
+ this.root.render(React.createElement(InspectPopup, { debugValue, onPinClicked, pinned: this.model.pinned }));
};
DebugInspectorPopup.prototype.dispose = function () {
- ReactDOM.unmountComponentAtNode(this.div);
+ if (this.root) {
+ this.root.unmount();
+ this.root = null;
+ }
if (this.div.parentElement) {
this.div.parentElement.removeChild(this.div);
}
diff --git a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
index c839780..a639e0b 100644
--- a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
+++ b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
@@ -227,7 +227,8 @@ export class NodeGraphEditor extends View {
nodesIdsAnimating: string[];
isPlayingNodeAnimations: boolean;
- toolbarRoots: Root[];
+ toolbarRoots: Root[] = [];
+ titleRoot: Root = null;
constructor(args) {
super();
@@ -1421,7 +1422,12 @@ export class NodeGraphEditor extends View {
updateTitle() {
const rootElem = this.el[0].querySelector('.nodegraph-component-trail-root');
- const root = createRoot(rootElem);
+
+ // Create root only once, reuse for subsequent renders
+ if (!this.titleRoot) {
+ this.titleRoot = createRoot(rootElem);
+ }
+
if (this.activeComponent) {
const fullName = this.activeComponent.fullName;
const nameParts = fullName.split('/');
@@ -1466,9 +1472,9 @@ export class NodeGraphEditor extends View {
canNavigateForward: this.navigationHistory.canNavigateForward
};
- root.render(React.createElement(NodeGraphComponentTrail, props));
+ this.titleRoot.render(React.createElement(NodeGraphComponentTrail, props));
} else {
- root.unmount();
+ this.titleRoot.render(null);
}
}