Added three new experimental views

This commit is contained in:
Richard Osborne
2026-01-04 00:17:33 +01:00
parent 2845b1b879
commit eb90c5a9c8
110 changed files with 24591 additions and 2570 deletions

View File

@@ -0,0 +1,373 @@
# Canvas Overlay Architecture
## Overview
This document explains how canvas overlays integrate with the NodeGraphEditor and the editor's data flow.
## Integration Points
### 1. NodeGraphEditor Initialization
The overlay is created when the NodeGraphEditor is constructed:
```typescript
// In nodegrapheditor.ts constructor
export default class NodeGraphEditor {
commentLayer: CommentLayer;
constructor(domElement, options) {
// ... canvas setup
// Create overlay
this.commentLayer = new CommentLayer(this);
this.commentLayer.setReadOnly(this.readOnly);
}
}
```
### 2. DOM Structure
The overlay requires two divs in the DOM hierarchy:
```html
<div id="nodegraph-editor">
<canvas id="nodegraph-canvas"></canvas>
<div id="nodegraph-background-layer"></div>
<!-- Behind canvas -->
<div id="nodegraph-dom-layer"></div>
<!-- In front of canvas -->
</div>
```
CSS z-index layering:
- Background layer: `z-index: 0`
- Canvas: `z-index: 1`
- Foreground layer: `z-index: 2`
### 3. Render Target Setup
The overlay attaches to the DOM layers:
```typescript
// In nodegrapheditor.ts
const backgroundDiv = this.el.find('#nodegraph-background-layer').get(0);
const foregroundDiv = this.el.find('#nodegraph-dom-layer').get(0);
this.commentLayer.renderTo(backgroundDiv, foregroundDiv);
```
### 4. Viewport Synchronization
The overlay updates whenever the canvas pan/zoom changes:
```typescript
// In nodegrapheditor.ts paint() method
paint() {
// ... canvas drawing
// Update overlay transform
this.commentLayer.setPanAndScale({
x: this.xOffset,
y: this.yOffset,
scale: this.scale
});
}
```
## Data Flow
### EventDispatcher Integration
Overlays typically subscribe to model changes using EventDispatcher:
```typescript
class MyOverlay {
setComponentModel(model: ComponentModel) {
if (this.model) {
this.model.off(this); // Clean up old subscriptions
}
this.model = model;
// Subscribe to changes
model.on('nodeAdded', this.onNodeAdded.bind(this), this);
model.on('nodeRemoved', this.onNodeRemoved.bind(this), this);
model.on('connectionChanged', this.onConnectionChanged.bind(this), this);
this.render();
}
onNodeAdded(node) {
// Update overlay state
this.render();
}
}
```
### Typical Data Flow
```
User Action
Model Change (ProjectModel/ComponentModel)
EventDispatcher fires event
Overlay handler receives event
Overlay updates React state
React re-renders overlay
```
## Lifecycle Management
### Creation
```typescript
constructor(nodegraphEditor: NodeGraphEditor) {
this.nodegraphEditor = nodegraphEditor;
this.props = { /* initial state */ };
}
```
### Attachment
```typescript
renderTo(backgroundDiv: HTMLDivElement, foregroundDiv: HTMLDivElement) {
this.backgroundDiv = backgroundDiv;
this.foregroundDiv = foregroundDiv;
// Create React roots
this.backgroundRoot = createRoot(backgroundDiv);
this.foregroundRoot = createRoot(foregroundDiv);
// Initial render
this._renderReact();
}
```
### Updates
```typescript
setPanAndScale(viewport: Viewport) {
// Update CSS transform
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
this.backgroundDiv.style.transform = transform;
this.foregroundDiv.style.transform = transform;
// Notify React if scale changed (important for react-rnd)
if (this.props.scale !== viewport.scale) {
this.props.scale = viewport.scale;
this._renderReact();
}
}
```
### Disposal
```typescript
dispose() {
// Unmount React
if (this.backgroundRoot) {
this.backgroundRoot.unmount();
}
if (this.foregroundRoot) {
this.foregroundRoot.unmount();
}
// Unsubscribe from models
if (this.model) {
this.model.off(this);
}
// Clean up DOM event listeners
// (CommentLayer uses a clever cloneNode trick to remove all listeners)
}
```
## Component Model Integration
### Accessing Graph Data
The overlay has access to the full component graph through NodeGraphEditor:
```typescript
class MyOverlay {
getNodesInView(): NodeGraphNode[] {
const model = this.nodegraphEditor.nodeGraphModel;
const nodes: NodeGraphNode[] = [];
model.forEachNode((node) => {
nodes.push(node);
});
return nodes;
}
getConnections(): Connection[] {
const model = this.nodegraphEditor.nodeGraphModel;
return model.getAllConnections();
}
}
```
### Node Position Access
Node positions are available through the graph model:
```typescript
getNodeScreenPosition(nodeId: string): Point | null {
const model = this.nodegraphEditor.nodeGraphModel;
const node = model.findNodeWithId(nodeId);
if (!node) return null;
// Node positions are in canvas space
return {
x: node.x,
y: node.y
};
}
```
## Communication with NodeGraphEditor
### From Overlay to Canvas
The overlay can trigger canvas operations:
```typescript
// Clear canvas selection
this.nodegraphEditor.clearSelection();
// Select nodes on canvas
this.nodegraphEditor.selectNode(node);
// Trigger repaint
this.nodegraphEditor.repaint();
// Navigate to node
this.nodegraphEditor.zoomToFitNodes([node]);
```
### From Canvas to Overlay
The canvas notifies the overlay of changes:
```typescript
// In nodegrapheditor.ts
selectNode(node) {
// ... canvas logic
// Notify overlay
this.commentLayer.clearSelection();
}
```
## Best Practices
### ✅ Do
1. **Clean up subscriptions** - Always unsubscribe from EventDispatcher on dispose
2. **Use the context object pattern** - Pass `this` as context to EventDispatcher subscriptions
3. **Batch updates** - Group multiple state changes before calling render
4. **Check for existence** - Always check if DOM elements exist before using them
### ❌ Don't
1. **Don't modify canvas directly** - Work through NodeGraphEditor API
2. **Don't store duplicate data** - Reference the model as the source of truth
3. **Don't subscribe without context** - Direct EventDispatcher subscriptions leak
4. **Don't assume initialization order** - Check for null before accessing properties
## Example: Complete Overlay Setup
```typescript
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { ComponentModel } from '@noodl-models/componentmodel';
import { NodeGraphEditor } from './nodegrapheditor';
export default class DataLineageOverlay {
private nodegraphEditor: NodeGraphEditor;
private model: ComponentModel;
private root: Root;
private container: HTMLDivElement;
private viewport: Viewport;
constructor(nodegraphEditor: NodeGraphEditor) {
this.nodegraphEditor = nodegraphEditor;
}
renderTo(container: HTMLDivElement) {
this.container = container;
this.root = createRoot(container);
this.render();
}
setComponentModel(model: ComponentModel) {
if (this.model) {
this.model.off(this);
}
this.model = model;
if (model) {
model.on('connectionChanged', this.onDataChanged.bind(this), this);
model.on('nodeRemoved', this.onDataChanged.bind(this), this);
}
this.render();
}
setPanAndScale(viewport: Viewport) {
this.viewport = viewport;
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
this.container.style.transform = transform;
}
private onDataChanged() {
this.render();
}
private render() {
if (!this.root) return;
const paths = this.calculateDataPaths();
this.root.render(
<DataLineageView paths={paths} viewport={this.viewport} onPathClick={this.handlePathClick.bind(this)} />
);
}
private calculateDataPaths() {
// Analyze graph connections to build data flow paths
// ...
}
private handlePathClick(path: DataPath) {
// Select nodes involved in this path
const nodeIds = path.nodes.map((n) => n.id);
this.nodegraphEditor.selectNodes(nodeIds);
}
dispose() {
if (this.root) {
this.root.unmount();
}
if (this.model) {
this.model.off(this);
}
}
}
```
## Related Documentation
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
- [Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md)
- [React Integration](./CANVAS-OVERLAY-REACT.md)

View File

@@ -0,0 +1,328 @@
# Canvas Overlay Coordinate Transforms
## Overview
This document explains how coordinate transformation works between canvas space and screen space in overlay systems.
## Coordinate Systems
### Canvas Space (Graph Space)
- **Origin**: Arbitrary (user-defined)
- **Units**: Graph units (nodes have x, y positions)
- **Affected by**: Nothing - absolute positions in the graph
- **Example**: Node at `{ x: 500, y: 300 }` in canvas space
### Screen Space (Pixel Space)
- **Origin**: Top-left of the canvas element
- **Units**: CSS pixels
- **Affected by**: Pan and zoom transformations
- **Example**: Same node might be at `{ x: 800, y: 450 }` on screen when zoomed in
## The Transform Strategy
CommentLayer uses CSS transforms on the container to handle all coordinate transformation automatically:
```typescript
setPanAndScale(viewport: { x: number; y: number; scale: number }) {
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
this.container.style.transform = transform;
}
```
### Why This Is Brilliant
1. **No per-element calculations** - Set transform once on container
2. **Browser-optimized** - Hardware accelerated CSS transforms
3. **Simple** - Child elements automatically transform
4. **Performant** - Avoids layout thrashing
### How It Works
```
User pans/zooms canvas
NodeGraphEditor.paint() called
overlay.setPanAndScale({ x, y, scale })
CSS transform applied to container
Browser automatically transforms all children
```
## Transform Math (If You Need It)
Sometimes you need manual transformations (e.g., calculating if a point hits an element):
### Canvas to Screen
```typescript
function canvasToScreen(
canvasPoint: { x: number; y: number },
viewport: { x: number; y: number; scale: number }
): { x: number; y: number } {
return {
x: (canvasPoint.x + viewport.x) * viewport.scale,
y: (canvasPoint.y + viewport.y) * viewport.scale
};
}
```
**Example:**
```typescript
const nodePos = { x: 100, y: 200 }; // Canvas space
const viewport = { x: 50, y: 30, scale: 1.5 };
const screenPos = canvasToScreen(nodePos, viewport);
// Result: { x: 225, y: 345 }
```
### Screen to Canvas
```typescript
function screenToCanvas(
screenPoint: { x: number; y: number },
viewport: { x: number; y: number; scale: number }
): { x: number; y: number } {
return {
x: screenPoint.x / viewport.scale - viewport.x,
y: screenPoint.y / viewport.scale - viewport.y
};
}
```
**Example:**
```typescript
const clickPos = { x: 225, y: 345 }; // Screen pixels
const viewport = { x: 50, y: 30, scale: 1.5 };
const canvasPos = screenToCanvas(clickPos, viewport);
// Result: { x: 100, y: 200 }
```
## React Component Positioning
### Using Transform (Preferred)
React components positioned in canvas space:
```tsx
function OverlayElement({ x, y, children }: Props) {
return (
<div
style={{
position: 'absolute',
left: x, // Canvas coordinates
top: y
// Parent container handles transform!
}}
>
{children}
</div>
);
}
```
The parent container's CSS transform automatically converts canvas coords to screen coords.
### Manual Calculation (Avoid)
Only if you must position outside the transformed container:
```tsx
function OverlayElement({ x, y, viewport, children }: Props) {
const screenPos = canvasToScreen({ x, y }, viewport);
return (
<div
style={{
position: 'absolute',
left: screenPos.x,
top: screenPos.y
}}
>
{children}
</div>
);
}
```
## Common Patterns
### Pattern 1: Node Overlay Badge
Show a badge on a specific node:
```tsx
function NodeBadge({ nodeId, nodegraphEditor }: Props) {
const node = nodegraphEditor.nodeGraphModel.findNodeWithId(nodeId);
if (!node) return null;
// Use canvas coordinates directly
return (
<div
style={{
position: 'absolute',
left: node.x + node.w, // Right edge of node
top: node.y
}}
>
<Badge>!</Badge>
</div>
);
}
```
### Pattern 2: Connection Path Highlight
Highlight a connection between two nodes:
```tsx
function ConnectionHighlight({ fromNode, toNode }: Props) {
// Calculate path in canvas space
const path = `M ${fromNode.x} ${fromNode.y} L ${toNode.x} ${toNode.y}`;
return (
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}
>
<path d={path} stroke="blue" strokeWidth={3} />
</svg>
);
}
```
### Pattern 3: Mouse Hit Testing
Determine if a click hits an overlay element:
```typescript
function handleMouseDown(evt: MouseEvent) {
// Get click position relative to canvas
const canvasElement = this.nodegraphEditor.canvasElement;
const rect = canvasElement.getBoundingClientRect();
const screenPos = {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
// Convert to canvas space for hit testing
const canvasPos = this.nodegraphEditor.relativeCoordsToNodeGraphCords(screenPos);
// Check if click hits any of our elements
const hitElement = this.elements.find((el) => pointInsideRectangle(canvasPos, el.bounds));
}
```
## Scale Considerations
### Scale-Dependent Sizes
Some overlay elements should scale with the canvas:
```tsx
// Node comment - scales with canvas
<div
style={{
position: 'absolute',
left: node.x,
top: node.y,
width: 200, // Canvas units - scales automatically
fontSize: 14 // Canvas units - scales automatically
}}
>
{comment}
</div>
```
### Scale-Independent Sizes
Some elements should stay the same pixel size regardless of zoom:
```tsx
// Control button - stays same size
<div
style={{
position: 'absolute',
left: node.x,
top: node.y,
width: 20 / viewport.scale, // Inverse scale
height: 20 / viewport.scale,
fontSize: 12 / viewport.scale
}}
>
×
</div>
```
## Best Practices
### ✅ Do
1. **Use container transform** - Let CSS do the work
2. **Store positions in canvas space** - Easier to reason about
3. **Calculate once** - Transform in render, not on every frame
4. **Cache viewport** - Store current viewport for calculations
### ❌ Don't
1. **Don't recalculate on every mouse move** - Only when needed
2. **Don't mix coordinate systems** - Be consistent
3. **Don't forget about scale** - Always consider zoom level
4. **Don't transform twice** - Either container OR manual, not both
## Debugging Tips
### Visualize Coordinate Systems
```tsx
function CoordinateDebugger({ viewport }: Props) {
return (
<>
{/* Canvas origin */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
width: 10,
height: 10,
background: 'red'
}}
/>
{/* Grid lines every 100 canvas units */}
{Array.from({ length: 20 }, (_, i) => (
<line key={i} x1={i * 100} y1={0} x2={i * 100} y2={2000} stroke="rgba(255,0,0,0.1)" />
))}
</>
);
}
```
### Log Transforms
```typescript
console.log('Canvas pos:', { x: node.x, y: node.y });
console.log('Viewport:', viewport);
console.log('Screen pos:', canvasToScreen({ x: node.x, y: node.y }, viewport));
```
## Related Documentation
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
- [Mouse Events](./CANVAS-OVERLAY-EVENTS.md)

View File

@@ -0,0 +1,314 @@
# Canvas Overlay Mouse Event Handling
## Overview
This document explains how mouse events are handled when overlays sit in front of the canvas. This is complex because events hit the overlay first but sometimes need to be routed to the canvas.
## The Challenge
```
DOM Layering:
┌─────────────────────┐ ← Mouse events hit here first
│ Foreground Overlay │ (z-index: 2)
├─────────────────────┤
│ Canvas │ (z-index: 1)
├─────────────────────┤
│ Background Overlay │ (z-index: 0)
└─────────────────────┘
```
When the user clicks:
1. Does it hit overlay UI (button, resize handle)?
2. Does it hit a node visible through the overlay?
3. Does it hit empty space?
The overlay must intelligently decide whether to handle or forward the event.
## CommentLayer's Solution
### Step 1: Capture All Mouse Events
Attach listeners to the foreground overlay div:
```typescript
setupMouseEventHandling(foregroundDiv: HTMLDivElement) {
const events = {
mousedown: 'down',
mouseup: 'up',
mousemove: 'move',
click: 'click'
};
for (const eventName in events) {
foregroundDiv.addEventListener(eventName, (evt) => {
this.handleMouseEvent(evt, events[eventName]);
}, true); // Capture phase!
}
}
```
### Step 2: Check for Overlay UI
```typescript
handleMouseEvent(evt: MouseEvent, type: string) {
// Is this an overlay control?
if (evt.target && evt.target.closest('.comment-controls')) {
// Let it through - user is interacting with overlay UI
return;
}
// Otherwise, check if canvas should handle it...
}
```
### Step 3: Forward to Canvas if Needed
```typescript
// Convert mouse position to canvas coordinates
const tl = this.nodegraphEditor.topLeftCanvasPos;
const pos = {
x: evt.pageX - tl[0],
y: evt.pageY - tl[1]
};
// Ask canvas if it wants this event
const consumed = this.nodegraphEditor.mouse(type, pos, evt, {
eventPropagatedFromCommentLayer: true
});
if (consumed) {
// Canvas handled it (e.g., hit a node)
evt.stopPropagation();
evt.preventDefault();
}
```
## Event Flow Diagram
```
Mouse Click
Foreground Overlay receives event
Is target .comment-controls?
├─ Yes → Let event propagate normally (overlay handles)
└─ No → Continue checking
Forward to NodeGraphEditor.mouse()
Did canvas consume event?
├─ Yes → Stop propagation (canvas handled)
└─ No → Let event propagate (overlay handles)
```
## Preventing Infinite Loops
The `eventPropagatedFromCommentLayer` flag prevents recursion:
```typescript
// In NodeGraphEditor
mouse(type, pos, evt, args) {
// Don't start another check if this came from overlay
if (args && args.eventPropagatedFromCommentLayer) {
// Just check if we hit something
const hitNode = this.findNodeAtPosition(pos);
return !!hitNode;
}
// Normal mouse handling...
}
```
## Pointer Events CSS
Use `pointer-events` to control which elements receive events:
```css
/* Overlay container - pass through clicks */
.overlay-container {
pointer-events: none;
}
/* But controls receive clicks */
.overlay-controls {
pointer-events: auto;
}
```
## Mouse Wheel Handling
Wheel events have special handling:
```typescript
foregroundDiv.addEventListener('wheel', (evt) => {
// Allow scroll in textarea
if (evt.target.tagName === 'TEXTAREA' && !evt.ctrlKey && !evt.metaKey) {
return; // Let it scroll
}
// Otherwise zoom the canvas
const tl = this.nodegraphEditor.topLeftCanvasPos;
this.nodegraphEditor.handleMouseWheelEvent(evt, {
offsetX: evt.pageX - tl[0],
offsetY: evt.pageY - tl[1]
});
});
```
## Click vs Down/Up
NodeGraphEditor doesn't use `click` events, only `down`/`up`. Handle this:
```typescript
let ignoreNextClick = false;
if (type === 'down' || type === 'up') {
if (consumed) {
// Canvas consumed the up/down, so ignore the click that follows
ignoreNextClick = true;
setTimeout(() => { ignoreNextClick = false; }, 1000);
}
}
if (type === 'click' && ignoreNextClick) {
ignoreNextClick = false;
evt.stopPropagation();
evt.preventDefault();
return;
}
```
## Multi-Select Drag Initiation
Start dragging selected nodes/comments from overlay:
```typescript
if (type === 'down') {
const hasSelection = this.props.selectedIds.length > 1 || this.nodegraphEditor.selector.active;
if (hasSelection) {
const canvasPos = this.nodegraphEditor.relativeCoordsToNodeGraphCords(pos);
// Check if starting drag on a selected item
const clickedItem = this.findItemAtPosition(canvasPos);
if (clickedItem && this.isSelected(clickedItem)) {
this.nodegraphEditor.startDraggingNodes(this.nodegraphEditor.selector.nodes);
evt.stopPropagation();
evt.preventDefault();
}
}
}
```
## Common Patterns
### Pattern 1: Overlay Button
```tsx
<button className="overlay-button" onClick={() => this.handleButtonClick()} style={{ pointerEvents: 'auto' }}>
Delete
</button>
```
The `className` check catches this button, event doesn't forward to canvas.
### Pattern 2: Draggable Overlay Element
```tsx
// Using react-rnd
<Rnd
position={{ x: comment.x, y: comment.y }}
onDragStart={() => {
// Disable canvas mouse events during drag
this.nodegraphEditor.setMouseEventsEnabled(false);
}}
onDragStop={() => {
// Re-enable canvas mouse events
this.nodegraphEditor.setMouseEventsEnabled(true);
}}
>
{content}
</Rnd>
```
### Pattern 3: Clickthrough SVG Overlay
```tsx
<svg
style={{
position: 'absolute',
pointerEvents: 'none', // Pass all events through
...
}}
>
<path d={highlightPath} stroke="blue" />
</svg>
```
## Keyboard Events
Forward keyboard events unless typing in an input:
```typescript
foregroundDiv.addEventListener('keydown', (evt) => {
if (evt.target.tagName === 'TEXTAREA' || evt.target.tagName === 'INPUT') {
// Let the input handle it
return;
}
// Forward to KeyboardHandler
KeyboardHandler.instance.executeCommandMatchingKeyEvent(evt, 'down');
});
```
## Best Practices
### ✅ Do
1. **Use capture phase** - `addEventListener(event, handler, true)`
2. **Check target element** - `evt.target.closest('.my-controls')`
3. **Prevent after handling** - Call `stopPropagation()` and `preventDefault()`
4. **Handle wheel specially** - Allow textarea scroll, forward canvas zoom
### ❌ Don't
1. **Don't forward everything** - Check if overlay should handle first
2. **Don't forget click events** - Handle the click/down/up difference
3. **Don't block all events** - Use `pointer-events: none` strategically
4. **Don't recurse** - Use flags to prevent infinite forwarding
## Debugging Tips
### Log Event Flow
```typescript
handleMouseEvent(evt, type) {
console.log('Event:', type, 'Target:', evt.target.className);
const consumed = this.nodegraphEditor.mouse(type, pos, evt, args);
console.log('Canvas consumed:', consumed);
}
```
### Visualize Hit Areas
```css
/* Temporarily add borders to debug */
.comment-controls {
border: 2px solid red !important;
}
```
### Check Pointer Events
```typescript
console.log('Pointer events:', window.getComputedStyle(element).pointerEvents);
```
## Related Documentation
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
- [Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)

View File

@@ -0,0 +1,179 @@
# Canvas Overlay Pattern
## Overview
**Status:** ✅ Proven Pattern (CommentLayer is production-ready)
**Location:** `packages/noodl-editor/src/editor/src/views/commentlayer.ts`
**Created:** Phase 4 PREREQ-003
This document describes the pattern for creating React overlays that float above the HTML5 Canvas in the Node Graph Editor. The pattern is proven and production-tested via CommentLayer.
## What This Pattern Enables
React components that:
- Float over the HTML5 Canvas
- Stay synchronized with canvas pan/zoom
- Handle mouse events intelligently (overlay vs canvas)
- Integrate with the existing EventDispatcher system
- Use modern React 19 APIs
## Why This Matters
Phase 4 visualization views need this pattern:
- **VIEW-005: Data Lineage** - Glowing path highlights
- **VIEW-006: Impact Radar** - Dependency visualization
- **VIEW-007: Semantic Layers** - Node visibility filtering
All of these require React UI floating over the canvas with proper coordinate transformation and event handling.
## Documentation Structure
This pattern is documented across several focused files:
1. **[Architecture Overview](./CANVAS-OVERLAY-ARCHITECTURE.md)** - How overlays integrate with NodeGraphEditor
2. **[Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)** - Canvas space ↔ Screen space conversion
3. **[Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md)** - Intelligent event routing
4. **[React Integration](./CANVAS-OVERLAY-REACT.md)** - React 19 patterns and lifecycle
5. **[Code Examples](./CANVAS-OVERLAY-EXAMPLES.md)** - Practical implementation examples
## Quick Start
### Minimal Overlay Example
```typescript
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { NodeGraphEditor } from './nodegrapheditor';
class SimpleOverlay {
private root: Root;
private container: HTMLDivElement;
constructor(private nodegraphEditor: NodeGraphEditor) {}
renderTo(container: HTMLDivElement) {
this.container = container;
this.root = createRoot(container);
this.render();
}
setPanAndScale(panAndScale: { x: number; y: number; scale: number }) {
const transform = `scale(${panAndScale.scale}) translate(${panAndScale.x}px, ${panAndScale.y}px)`;
this.container.style.transform = transform;
}
private render() {
this.root.render(<div>My Overlay Content</div>);
}
dispose() {
if (this.root) {
this.root.unmount();
}
}
}
```
### Integration with NodeGraphEditor
```typescript
// In nodegrapheditor.ts
this.myOverlay = new SimpleOverlay(this);
this.myOverlay.renderTo(overlayDiv);
// Update on pan/zoom
this.myOverlay.setPanAndScale(this.getPanAndScale());
```
## Key Insights from CommentLayer
### 1. CSS Transform Strategy (Brilliant!)
The entire overlay stays in sync via a single CSS transform on the container:
```typescript
const transform = `scale(${scale}) translate(${x}px, ${y}px)`;
container.style.transform = transform;
```
No complex calculations per element - the browser handles it all!
### 2. React Root Reuse
Create roots once, reuse for all re-renders:
```typescript
if (!this.root) {
this.root = createRoot(this.container);
}
this.root.render(<MyComponent {...props} />);
```
### 3. Two-Layer System
CommentLayer uses two layers:
- **Background layer** - Behind canvas (e.g., colored comment boxes)
- **Foreground layer** - In front of canvas (e.g., comment controls, resize handles)
This allows visual layering: comments behind nodes, but controls in front.
### 4. Mouse Event Forwarding
Complex but powerful: overlay determines if clicks should go to canvas or stay in overlay. See [Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md) for details.
## Common Gotchas
### ❌ Don't: Create new roots on every render
```typescript
// BAD - memory leak!
render() {
this.root = createRoot(this.container);
this.root.render(<Component />);
}
```
### ✅ Do: Create once, reuse
```typescript
// GOOD
constructor() {
this.root = createRoot(this.container);
}
render() {
this.root.render(<Component />);
}
```
### ❌ Don't: Manually calculate positions for every element
```typescript
// BAD - complex and slow
elements.forEach((el) => {
el.style.left = (el.x + pan.x) * scale + 'px';
el.style.top = (el.y + pan.y) * scale + 'px';
});
```
### ✅ Do: Use container transform
```typescript
// GOOD - browser handles it
container.style.transform = `scale(${scale}) translate(${pan.x}px, ${pan.y}px)`;
```
## Next Steps
- Read [Architecture Overview](./CANVAS-OVERLAY-ARCHITECTURE.md) to understand integration
- Review [CommentLayer source](../../packages/noodl-editor/src/editor/src/views/commentlayer.ts) for full example
- Check [Code Examples](./CANVAS-OVERLAY-EXAMPLES.md) for specific patterns
## Related Documentation
- [CommentLayer Implementation Analysis](./LEARNINGS.md#canvas-overlay-pattern)
- [Phase 4 Prerequisites](../tasks/phase-4-canvas-visualisation-views/PREREQ-003-canvas-overlay-pattern/)
- [NodeGraphEditor Integration](./CODEBASE-MAP.md#node-graph-editor)

View File

@@ -0,0 +1,337 @@
# Canvas Overlay React Integration
## Overview
This document covers React 19 specific patterns for canvas overlays, including root management, lifecycle, and common gotchas.
## React 19 Root API
CommentLayer uses the modern React 19 `createRoot` API:
```typescript
import { createRoot, Root } from 'react-dom/client';
class MyOverlay {
private backgroundRoot: Root;
private foregroundRoot: Root;
renderTo(backgroundDiv: HTMLDivElement, foregroundDiv: HTMLDivElement) {
// Create roots once
this.backgroundRoot = createRoot(backgroundDiv);
this.foregroundRoot = createRoot(foregroundDiv);
// Render
this._renderReact();
}
private _renderReact() {
this.backgroundRoot.render(<Background {...this.props} />);
this.foregroundRoot.render(<Foreground {...this.props} />);
}
dispose() {
this.backgroundRoot.unmount();
this.foregroundRoot.unmount();
}
}
```
## Key Pattern: Root Reuse
**✅ Create once, render many times:**
```typescript
// Good - root created once in constructor/setup
constructor() {
this.root = createRoot(this.container);
}
updateData() {
// Reuse existing root
this.root.render(<Component data={this.newData} />);
}
```
**❌ Never recreate roots:**
```typescript
// Bad - memory leak!
updateData() {
this.root = createRoot(this.container); // Creates new root every time
this.root.render(<Component data={this.newData} />);
}
```
## State Management
### Props Pattern (CommentLayer's Approach)
Store state in the overlay class, pass as props:
```typescript
class DataLineageOverlay {
private props: {
paths: DataPath[];
selectedPath: string | null;
viewport: Viewport;
};
constructor() {
this.props = {
paths: [],
selectedPath: null,
viewport: { x: 0, y: 0, scale: 1 }
};
}
setSelectedPath(pathId: string) {
this.props.selectedPath = pathId;
this.render();
}
private render() {
this.root.render(<LineageView {...this.props} />);
}
}
```
### React State (If Needed)
For complex overlays, use React state internally:
```typescript
function LineageView({ paths, onPathSelect }: Props) {
const [hoveredPath, setHoveredPath] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
return (
<div>
{paths.map((path) => (
<PathHighlight
key={path.id}
path={path}
isHovered={hoveredPath === path.id}
onMouseEnter={() => setHoveredPath(path.id)}
onMouseLeave={() => setHoveredPath(null)}
onClick={() => onPathSelect(path.id)}
/>
))}
</div>
);
}
```
## Scale Prop Special Case
**Important:** react-rnd needs `scale` prop on mount for proper setup:
```typescript
setPanAndScale(viewport: Viewport) {
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
this.container.style.transform = transform;
// Must re-render if scale changed (for react-rnd)
if (this.props.scale !== viewport.scale) {
this.props.scale = viewport.scale;
this._renderReact();
}
}
```
From CommentLayer:
```tsx
// react-rnd requires "scale" to be set when this mounts
if (props.scale === undefined) {
return null; // Don't render until scale is set
}
```
## Async Rendering Workaround
React effects that trigger renders cause warnings. Use setTimeout:
```typescript
renderTo(container: HTMLDivElement) {
this.container = container;
this.root = createRoot(container);
// Ugly workaround to avoid React warnings
// when mounting inside another React effect
setTimeout(() => {
this._renderReact();
}, 1);
}
```
## Performance Optimization
### Memoization
```tsx
import { memo, useMemo } from 'react';
const PathHighlight = memo(function PathHighlight({ path, viewport }: Props) {
// Expensive path calculation
const svgPath = useMemo(() => {
return calculateSVGPath(path.nodes, viewport);
}, [path.nodes, viewport.scale]); // Re-calc only when needed
return <path d={svgPath} stroke="blue" strokeWidth={3} />;
});
```
### Virtualization
For many overlay elements (100+), consider virtualization:
```tsx
import { FixedSizeList } from 'react-window';
function ManyOverlayElements({ items, viewport }: Props) {
return (
<FixedSizeList height={viewport.height} itemCount={items.length} itemSize={50} width={viewport.width}>
{({ index, style }) => (
<div style={style}>
<OverlayElement item={items[index]} />
</div>
)}
</FixedSizeList>
);
}
```
## Common Patterns
### Pattern 1: Conditional Rendering Based on Scale
```tsx
function AdaptiveOverlay({ scale }: Props) {
// Hide detailed UI when zoomed out
if (scale < 0.5) {
return <SimplifiedView />;
}
return <DetailedView />;
}
```
### Pattern 2: Portal for Tooltips
Tooltips should escape the transformed container:
```tsx
import { createPortal } from 'react-dom';
function OverlayWithTooltip({ tooltip }: Props) {
const [showTooltip, setShowTooltip] = useState(false);
return (
<>
<div onMouseEnter={() => setShowTooltip(true)}>Hover me</div>
{showTooltip &&
createPortal(
<Tooltip>{tooltip}</Tooltip>,
document.body // Render outside transformed container
)}
</>
);
}
```
### Pattern 3: React + External Library (react-rnd)
CommentLayer uses react-rnd for draggable comments:
```tsx
import { Rnd } from 'react-rnd';
<Rnd
position={{ x: comment.x, y: comment.y }}
size={{ width: comment.w, height: comment.h }}
scale={scale} // Pass viewport scale
onDragStop={(e, d) => {
updateComment(
comment.id,
{
x: d.x,
y: d.y
},
{ commit: true }
);
}}
onResizeStop={(e, direction, ref, delta, position) => {
updateComment(
comment.id,
{
x: position.x,
y: position.y,
w: ref.offsetWidth,
h: ref.offsetHeight
},
{ commit: true }
);
}}
>
{content}
</Rnd>;
```
## Gotchas
### ❌ Gotcha 1: Transform Affects Event Coordinates
```tsx
// Event coordinates are in screen space, not canvas space
function handleClick(evt: React.MouseEvent) {
// Wrong - these are screen coordinates
console.log(evt.clientX, evt.clientY);
// Need to convert to canvas space
const canvasPos = screenToCanvas({ x: evt.clientX, y: evt.clientY }, viewport);
}
```
### ❌ Gotcha 2: CSS Transform Affects Children
All children inherit the container transform. For fixed-size UI:
```tsx
<div
style={{
// This size will be scaled by container transform
width: 20 / scale, // Compensate for scale
height: 20 / scale
}}
>
Fixed size button
</div>
```
### ❌ Gotcha 3: React Dev Tools Performance
React Dev Tools can slow down overlays with many elements. Disable in production builds.
## Best Practices
### ✅ Do
1. **Create roots once** - In constructor/renderTo, not on every render
2. **Memoize expensive calculations** - Use useMemo for complex math
3. **Use React.memo for components** - Especially for list items
4. **Handle scale changes** - Re-render when scale changes (for react-rnd)
### ❌ Don't
1. **Don't recreate roots** - Causes memory leaks
2. **Don't render before scale is set** - react-rnd breaks
3. **Don't forget to unmount** - Call `root.unmount()` in dispose()
4. **Don't use useState in overlay class** - Use class properties + props
## Related Documentation
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
- [Mouse Events](./CANVAS-OVERLAY-EVENTS.md)
- [Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)

View File

@@ -0,0 +1,192 @@
# Debug Infrastructure
> **Purpose:** Documents Noodl's existing runtime debugging capabilities that the Trigger Chain Debugger will extend.
**Status:** Initial documentation (Phase 1A of VIEW-003)
**Last Updated:** January 3, 2026
---
## Overview
Noodl has powerful runtime debugging that shows what's happening in the preview window:
- **Connection pulsing** - Connections animate when data flows
- **Inspector values** - Shows live data in pinned inspectors
- **Runtime→Editor bridge** - Events flow from preview to editor canvas
The Trigger Chain Debugger extends this by **recording** these events into a reviewable timeline.
---
## DebugInspector System
**Location:** `packages/noodl-editor/src/editor/src/utils/debuginspector.js`
### Core Components
#### 1. `DebugInspector` (Singleton)
Manages connection pulse animations and inspector values.
**Key Properties:**
```javascript
{
connectionsToPulseState: {}, // Active pulsing connections
connectionsToPulseIDs: [], // Cached array of IDs
inspectorValues: {}, // Current inspector values
enabled: true // Debug mode toggle
}
```
**Key Methods:**
- `setConnectionsToPulse(connections)` - Start pulsing connections
- `setInspectorValues(inspectorValues)` - Update inspector data
- `isConnectionPulsing(connection)` - Check if connection is animating
- `valueForConnection(connection)` - Get current value
- `reset()` - Clear all debug state
#### 2. `DebugInspector.InspectorsModel`
Manages pinned inspector positions and persistence.
**Key Methods:**
- `addInspectorForConnection(args)` - Pin a connection inspector
- `addInspectorForNode(args)` - Pin a node inspector
- `removeInspector(inspector)` - Unpin inspector
---
## Event Flow
```
┌─────────────────────────────────────────────────────────────┐
│ RUNTIME (Preview) │
│ │
│ Node executes → Data flows → Connection pulses │
│ │
│ │ │
│ ▼ │
│ Sends event to editor │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ VIEWER CONNECTION │
│ │
│ - Receives 'debuginspectorconnectionpulse' command │
│ - Receives 'debuginspectorvalues' command │
│ - Forwards to DebugInspector │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ DEBUG INSPECTOR │
│ │
│ - Updates connectionsToPulseState │
│ - Updates inspectorValues │
│ - Notifies listeners │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ NODE GRAPH EDITOR │
│ │
│ - Subscribes to 'DebugInspectorConnectionPulseChanged' │
│ - Animates connections on canvas │
└─────────────────────────────────────────────────────────────┘
```
---
## Events Emitted
DebugInspector uses `EventDispatcher` to notify listeners:
| Event Name | When Fired | Data |
| ----------------------------------------- | ----------------------- | ----------- |
| `DebugInspectorConnectionPulseChanged` | Connection pulse state | None |
| `DebugInspectorDataChanged.<inspectorId>` | Inspector value updated | `{ value }` |
| `DebugInspectorReset` | Debug state cleared | None |
| `DebugInspectorEnabledChanged` | Debug mode toggled | None |
---
## ViewerConnection Bridge
**Location:** `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
### Commands from Runtime
| Command | Content | Handler |
| ------------------------------- | ------------------------ | ------------------------- |
| `debuginspectorconnectionpulse` | `{ connectionsToPulse }` | `setConnectionsToPulse()` |
| `debuginspectorvalues` | `{ inspectors }` | `setInspectorValues()` |
### Commands to Runtime
| Command | Content | Purpose |
| ----------------------- | ---------------- | -------------------------------- |
| `debuginspector` | `{ inspectors }` | Send inspector config to runtime |
| `debuginspectorenabled` | `{ enabled }` | Enable/disable debug mode |
---
## Connection Pulse Animation
Connections "pulse" when data flows through them:
1. Runtime detects connection activity
2. Sends connection ID to editor
3. DebugInspector adds to `connectionsToPulseState`
4. Animation frame loop updates opacity/offset
5. Canvas redraws with animated styling
**Animation Properties:**
```javascript
{
created: timestamp, // When pulse started
offset: number, // Animation offset (life / 20)
opacity: number, // Fade in/out (0-1)
removed: timestamp // When pulse ended (or false)
}
```
---
## For Trigger Chain Recorder
**What we can leverage:**
**Connection pulse events** - Tells us when nodes fire
**Inspector values** - Gives us data flowing through connections
**ViewerConnection bridge** - Already connects runtime↔editor
**Event timing** - `performance.now()` used for timestamps
**What we need to add:**
**Causal tracking** - What triggered what?
**Component boundaries** - When entering/exiting components
**Event persistence** - Currently only shows "now", we need history
**Node types** - What kind of node fired (REST, Variable, etc.)
---
## Next Steps (Phase 1B)
1. Investigate runtime node execution hooks
2. Find where to intercept node events
3. Determine how to track causality
4. Design TriggerChainRecorder interface
---
## References
- `packages/noodl-editor/src/editor/src/utils/debuginspector.js`
- `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` (pulse rendering)

View File

@@ -4,6 +4,329 @@ This document captures important discoveries and gotchas encountered during Open
---
## 🎨 Canvas Overlay Pattern: React Over HTML5 Canvas (Jan 3, 2026)
### The Transform Trick: CSS scale() + translate() for Automatic Coordinate Transformation
**Context**: Phase 4 PREREQ-003 - Studying CommentLayer to understand how React components overlay the HTML5 Canvas node graph. Need to build Data Lineage, Impact Radar, and Semantic Layer visualizations using the same pattern.
**The Discovery**: The most elegant solution for overlaying React on Canvas uses CSS transforms on a parent container. Child React components automatically position themselves in canvas coordinates without manual recalculation.
**The Pattern**:
```typescript
// ❌ WRONG - Manual coordinate transformation for every element
function OverlayComponent({ node, viewport }) {
const screenX = (node.x + viewport.pan.x) * viewport.scale;
const screenY = (node.y + viewport.pan.y) * viewport.scale;
return <div style={{ left: screenX, top: screenY }}>...</div>;
// Problem: Must recalculate for every element, every render
}
// ✅ RIGHT - CSS transform on parent container
function OverlayContainer({ children, viewport }) {
return (
<div
style={{
transform: `scale(${viewport.scale}) translate(${viewport.pan.x}px, ${viewport.pan.y}px)`,
transformOrigin: '0 0'
}}
>
{children}
{/* All children automatically positioned in canvas coordinates! */}
</div>
);
}
// React children use canvas coordinates directly
function NodeBadge({ node }) {
return (
<div style={{ position: 'absolute', left: node.x, top: node.y }}>
{/* Works perfectly - transform handles the rest */}
</div>
);
}
```
**Why This Matters**:
- **Automatic transformation**: React children don't need coordinate math
- **Performance**: No per-element calculations on every render
- **Simplicity**: Overlay components use canvas coordinates naturally
- **Consistency**: Same coordinate system as canvas drawing code
**React 19 Root API Pattern** - Critical for overlays:
```typescript
// ❌ WRONG - Creates new root on every render (memory leak)
function updateOverlay() {
createRoot(container).render(<Overlay />); // ☠️ New root each time
}
// ✅ RIGHT - Create once, reuse forever
class CanvasOverlay {
private root: Root;
constructor(container: HTMLElement) {
this.root = createRoot(container); // Create once
}
render(props: OverlayProps) {
this.root.render(<Overlay {...props} />); // Reuse root
}
dispose() {
this.root.unmount(); // Clean up properly
}
}
```
**Two-Layer System** - CommentLayer's architecture:
```
┌─────────────────────────────────────┐
│ Foreground Layer (z-index: 2) │ ← Interactive controls
├─────────────────────────────────────┤
│ HTML5 Canvas (z-index: 1) │ ← Node graph
├─────────────────────────────────────┤
│ Background Layer (z-index: 0) │ ← Comment boxes with shadows
└─────────────────────────────────────┘
```
This allows:
- Comment boxes render **behind** canvas (no z-fighting with nodes)
- Interactive controls render **in front** of canvas (draggable handles)
- No z-index conflicts between overlay elements
**Mouse Event Forwarding** - The click-through solution:
```typescript
// Three-step pattern for handling clicks
overlayContainer.addEventListener('mousedown', (event) => {
// Step 1: Capture the event
const target = event.target as HTMLElement;
// Step 2: Check if clicking on actual UI
const clickedOnUI = target.style.pointerEvents !== 'none';
// Step 3: If not UI, forward to canvas
if (!clickedOnUI) {
const canvasEvent = new MouseEvent('mousedown', event);
canvasElement.dispatchEvent(canvasEvent);
}
});
```
**EventDispatcher Context Pattern** - Must use context object:
```typescript
// ✅ BEST - Use useEventListener hook (built-in context handling)
import { useEventListener } from '@noodl-hooks/useEventListener';
// ❌ WRONG - Direct subscription in React (breaks on cleanup)
useEffect(() => {
editor.on('viewportChanged', handler);
return () => editor.off('viewportChanged', handler); // ☠️ Can't unsubscribe
}, []);
// ✅ RIGHT - Use context object for cleanup
useEffect(() => {
const context = {};
editor.on('viewportChanged', handler, context);
return () => editor.off(context); // Removes all subscriptions with context
}, []);
useEventListener(editor, 'viewportChanged', (viewport) => {
// Automatically handles context and cleanup
});
```
**Scale-Dependent vs Scale-Independent Sizing**:
```scss
// Scale-dependent - Grows/shrinks with zoom
.node-badge {
font-size: 12px; // Affected by parent transform
padding: 4px;
}
// Scale-independent - Stays same size
.floating-panel {
position: fixed; // Not affected by transform
top: 20px;
right: 20px;
font-size: 14px; // Always 14px regardless of zoom
}
```
**Common Gotchas**:
1. **React-rnd scale prop**: Must set scale on mount, can't update dynamically
```typescript
// Set scale once when component mounts
<Rnd scale={this.scale} onMount={...} />
```
2. **Transform affects ALL children**: Can't exempt specific elements
- Solution: Use two overlays (one transformed, one not)
3. **Async rendering timing**: React 19 may batch updates
```typescript
// Force immediate render with setTimeout
setTimeout(() => this.root.render(<Overlay />), 0);
```
4. **EventDispatcher cleanup**: Must use context object, not direct references
**Documentation Created**:
- `CANVAS-OVERLAY-PATTERN.md` - Overview and quick start
- `CANVAS-OVERLAY-ARCHITECTURE.md` - Integration with NodeGraphEditor
- `CANVAS-OVERLAY-COORDINATES.md` - Coordinate transformation details
- `CANVAS-OVERLAY-EVENTS.md` - Mouse event handling
- `CANVAS-OVERLAY-REACT.md` - React 19 specific patterns
**Impact**: This pattern unblocks all Phase 4 visualization views:
- VIEW-005: Data Lineage (path highlighting)
- VIEW-006: Impact Radar (dependency visualization)
- VIEW-007: Semantic Layers (node filtering)
**Critical Rules**:
1. **Use CSS transform on parent** - Let CSS handle coordinate transformation
2. **Create React root once** - Reuse for all renders, unmount on disposal
3. **Use two layers when needed** - Background and foreground for z-index control
4. **Forward mouse events** - Check pointer-events before forwarding to canvas
5. **Use EventDispatcher context** - Never subscribe without context object
**Time Saved**: This documentation will save ~4-6 hours per visualization view by providing proven patterns instead of trial-and-error.
**Location**:
- Study file: `packages/noodl-editor/src/editor/src/views/nodegrapheditor/commentlayer.ts`
- Documentation: `dev-docs/reference/CANVAS-OVERLAY-*.md` (5 files)
- Task CHANGELOG: `dev-docs/tasks/phase-4-canvas-visualisation-views/PREREQ-003-canvas-overlay-pattern/CHANGELOG.md`
**Keywords**: canvas overlay, React over canvas, CSS transform, coordinate transformation, React 19, createRoot, EventDispatcher, mouse forwarding, pointer-events, two-layer system, CommentLayer, viewport, pan, zoom, scale
---
## 🔄 React UseMemo Array Reference Equality (Jan 3, 2026)
### The Invisible Update: When UseMemo Recalculates But React Doesn't Re-render
**Context**: Phase 2 TASK-008 - Sheet dropdown in Components Panel wasn't updating when sheets were created/deleted. Events fired correctly, useMemo recalculated correctly, but the UI didn't update.
**The Problem**: React's useMemo uses reference equality (`===`) to determine if a value has changed. Even when useMemo recalculates an array with new values, if the dependencies haven't changed by reference, React may return the same memoized reference, preventing child components from detecting the change.
**The Broken Pattern**:
```typescript
// ❌ WRONG - Recalculation doesn't guarantee new reference
const sheets = useMemo((): Sheet[] => {
const sheetSet = new Set<string>();
// ... calculate sheets ...
return result; // Same reference if deps unchanged
}, [rawComponents, allComponents, hideSheets]);
// Child component receives same array reference
<SheetSelector sheets={sheets} />; // No re-render!
```
**The Solution** - Add an update counter to force new references:
```typescript
// ✅ RIGHT - Update counter forces new reference
const [updateCounter, setUpdateCounter] = useState(0);
// Increment counter when model changes
useEffect(() => {
const handleUpdate = () => setUpdateCounter((c) => c + 1);
ProjectModel.instance.on(EVENTS, handleUpdate, group);
return () => ProjectModel.instance.off(group);
}, []);
// Counter in deps forces new reference on every recalculation
const sheets = useMemo((): Sheet[] => {
const sheetSet = new Set<string>();
// ... calculate sheets ...
return result; // New reference when updateCounter changes!
}, [rawComponents, allComponents, hideSheets, updateCounter]);
// Child component detects new reference and re-renders
<SheetSelector sheets={sheets} />; // Re-renders correctly!
```
**Why This Matters**:
- **useMemo is an optimization, not a guarantee**: It may return the cached value even when recalculating
- **Reference equality drives React updates**: Components only re-render when props change by reference
- **Update counters bypass the cache**: Changing a simple number in deps forces a full recalculation with a new reference
**The Debug Journey**:
1. ✅ Events fire correctly (componentAdded, componentRemoved)
2. ✅ Event handlers execute (updateCounter increments)
3. ✅ useMemo recalculates (new sheet values computed)
4. ❌ But child components don't re-render (same array reference)
**Common Symptoms**:
- Events fire but UI doesn't update
- Data is correct when logged but not displayed
- Refreshing the page shows correct state
- Direct state changes work but derived state doesn't
**Critical Rules**:
1. **Never assume useMemo creates new references** - It's an optimization, not a forcing mechanism
2. **Use update counters for event-driven data** - Simple incrementing values in deps force re-computation
3. **Always verify reference changes** - Log array/object references to confirm they change
4. **Test with React DevTools** - Check component re-render highlighting to confirm updates
**Alternative Patterns**:
```typescript
// Pattern 1: Force re-creation with spreading (less efficient)
const sheets = useMemo(() => {
const result = calculateSheets();
return [...result]; // Always new array
}, [deps, updateCounter]);
// Pattern 2: Skip useMemo for frequently-changing data
const sheets = calculateSheets(); // Recalculate every render
// Only use when calculation is cheap
// Pattern 3: Use useCallback for stable references with changing data
const getSheets = useCallback(() => {
return calculateSheets(); // Fresh calculation on every call
}, [deps]);
```
**Related Issues**:
- Similar to React's "stale closure" problem
- Related to React.memo's shallow comparison
- Connected to PureComponent update blocking
**Time Lost**: 2-3 hours debugging "why events work but UI doesn't update"
**Location**:
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts` (line 153)
- Task: Phase 2 TASK-008 ComponentsPanel Menus and Sheets
- CHANGELOG: `dev-docs/tasks/phase-2-react-migration/TASK-008-componentspanel-menus-and-sheets/CHANGELOG.md`
**Keywords**: React, useMemo, reference equality, array reference, update counter, force re-render, shallow comparison, React optimization, derived state, memoization
---
## 🚫 Port Hover Compatibility Highlighting Failed Attempt (Jan 1, 2026)
### The Invisible Compatibility: Why Port Hover Preview Didn't Work