diff --git a/web_ui/src/app/canvas/ChatNodeView.test.tsx b/web_ui/src/app/canvas/ChatNodeView.test.tsx index a5b81c6..82703ed 100644 --- a/web_ui/src/app/canvas/ChatNodeView.test.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.test.tsx @@ -1,3 +1,4 @@ +import { createElement } from "react"; import { ReactFlowProvider, type NodeProps } from "@xyflow/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -22,7 +23,19 @@ import { WsTransport } from "../../lib/ws/transport"; // delta count" without touching what gets rendered. vi.mock("./NodeMarkdown", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, NodeMarkdown: vi.fn(actual.NodeMarkdown) }; + // NodeMarkdown is React.memo(...) - a memo descriptor object, not a plain + // callable function - so it can no longer be handed to vi.fn() directly as + // the implementation (vi.fn calls implementation.apply(...) internally, and + // a memo object has no .apply). Spy on a plain wrapper function instead + // that renders an element of the REAL memoized component: call-count + // instrumentation keeps working exactly as before (every ChatNodeView + // re-render that reaches this JSX position still increments the spy, + // same as when NodeMarkdown was an unmemoized plain function), while the + // actual markdown parse still runs through the genuine memoized component. + return { + ...actual, + NodeMarkdown: vi.fn((props: { content: string }) => createElement(actual.NodeMarkdown, props)), + }; }); // R7.5a: jsdom implements neither URL.createObjectURL nor diff --git a/web_ui/src/app/canvas/NodeMarkdown.tsx b/web_ui/src/app/canvas/NodeMarkdown.tsx index 3d72e91..0cd61d3 100644 --- a/web_ui/src/app/canvas/NodeMarkdown.tsx +++ b/web_ui/src/app/canvas/NodeMarkdown.tsx @@ -1,5 +1,6 @@ -import { useRef, useState, type JSX } from "react"; +import { memo, useMemo, useRef, useState, type JSX } from "react"; import ReactMarkdown, { type ExtraProps } from "react-markdown"; +import type { PluggableList } from "unified"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { remarkAlert } from "remark-github-blockquote-alert"; @@ -105,6 +106,8 @@ import { rehypeHighlightSearchMatches } from "./documentViewSearchHighlight"; * both whether the link renders at all AND how hardened it is. */ +const REMARK_PLUGINS = [remarkGfm, remarkAlert, remarkMath]; + function CodeBlock({ node: _node, children, ...props }: JSX.IntrinsicElements["pre"] & ExtraProps) { const preRef = useRef(null); const [copied, setCopied] = useState(false); @@ -208,15 +211,17 @@ function SafeAnchor({ node: _node, href, children, ...props }: JSX.IntrinsicElem ); } -export function NodeMarkdown({ content }: { content: string }) { +const MARKDOWN_COMPONENTS = { pre: CodeBlock, table: TableWrapper, img: ZoomImage, a: SafeAnchor }; + +export const NodeMarkdown = memo(function NodeMarkdown({ content }: { content: string }) { const searchQuery = useCanvasSearchQuery(); + const rehypePlugins = useMemo( + () => [rehypeHighlight, rehypeKatex, [rehypeHighlightSearchMatches, searchQuery]], + [searchQuery], + ); return ( - + {content} ); -} +}); diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 59647d4..3fe2628 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -137,6 +137,15 @@ const EDGE_TYPES = { orthogonal: OrthogonalEdge, }; +// UI-perf fix: fully static props, hoisted so these are the +// SAME array/object reference on every CanvasInner render instead of a +// fresh literal every time - React Flow reads these by reference in its +// own internal effects, so a fresh literal every render meant those +// effects re-ran on every unrelated re-render for no behavioral reason. +const DELETE_KEY_CODES = ["Delete", "Backspace"]; +const PRO_OPTIONS = { hideAttribution: true }; +const DEFAULT_EDGE_OPTIONS = { type: "default" as const }; + // R8a follow-up: legacy's graphlink_window.py show_document_view() showed // this exact message (via notification_banner.show_message(..., "info")) // when a node had nothing to show, rather than silently doing nothing - the @@ -2546,8 +2555,11 @@ function CanvasInner({ if (settledMoveIntents.length > 0) store.moveNodes(settledMoveIntents); // Guides re-derive every drag frame (legacy cleared + re-added its // QGraphicsLineItems per recompute); drag end always clears. - if (sawDragging) setSmartGuideLines(frameGuides); - else if (sawDragEnd) setSmartGuideLines([]); + if (sawDragging) { + setSmartGuideLines((current) => (current.length === 0 && frameGuides.length === 0 ? current : frameGuides)); + } else if (sawDragEnd) { + setSmartGuideLines((current) => (current.length === 0 ? current : [])); + } setNodes((current) => applyNodeChanges([...scaled, ...memberChanges], current)); }, [nodes, scene.dragFactor, scene.smartGuides, reactFlow, store], @@ -2587,6 +2599,14 @@ function CanvasInner({ [store, nodes], ); + const onSelectionChange = useCallback( + ({ nodes: sel }: { nodes: { id: string }[] }) => handleSelectionChange(store, sel), + [store], + ); + const onEdgeMouseEnter = useCallback((_event: React.MouseEvent, edge: Edge) => setHoveredEdgeId(edge.id), []); + const onEdgeMouseLeave = useCallback(() => setHoveredEdgeId(null), []); + const snapGrid = useMemo<[number, number]>(() => [grid.gridSize, grid.gridSize], [grid.gridSize]); + const { screenToFlowPosition } = reactFlow; const onDoubleClick = useCallback( (event: React.MouseEvent) => { @@ -2625,20 +2645,20 @@ function CanvasInner({ // R5.1: mirrors React Flow's own selection state into the store so // PluginPicker can attach "which node was selected" to executePlugin // without either component reaching into the other's internals. - onSelectionChange={({ nodes: sel }) => handleSelectionChange(store, sel)} - onEdgeMouseEnter={(_event, edge) => setHoveredEdgeId(edge.id)} - onEdgeMouseLeave={() => setHoveredEdgeId(null)} + onSelectionChange={onSelectionChange} + onEdgeMouseEnter={onEdgeMouseEnter} + onEdgeMouseLeave={onEdgeMouseLeave} snapToGrid={scene.snapToGrid} - snapGrid={[grid.gridSize, grid.gridSize]} + snapGrid={snapGrid} // Double-click is the R1 create-node gesture (wrapper onDoubleClick); // RF's default dblclick-zoom would consume it before it ever bubbles. zoomOnDoubleClick={false} fitView minZoom={0.1} maxZoom={2.5} - deleteKeyCode={["Delete", "Backspace"]} - proOptions={{ hideAttribution: true }} - defaultEdgeOptions={{ type: "default" }} + deleteKeyCode={DELETE_KEY_CODES} + proOptions={PRO_OPTIONS} + defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} /* * ADR-011 stage 11.2: off-viewport nodes no longer mount at all (nor * re-render, nor pay their markdown/highlight/KaTeX parse cost) - diff --git a/web_ui/src/app/canvas/useLodVisibility.ts b/web_ui/src/app/canvas/useLodVisibility.ts index d2241d7..91cc273 100644 --- a/web_ui/src/app/canvas/useLodVisibility.ts +++ b/web_ui/src/app/canvas/useLodVisibility.ts @@ -13,6 +13,5 @@ import { LOD_ZOOM_THRESHOLD } from "./canvasConstants"; * since this only replaces the two lines that produced the boolean, not what * callers do with it afterward). */ export function useLodVisibility(): boolean { - const zoom = useStore((s) => s.transform[2]); - return zoom < LOD_ZOOM_THRESHOLD; + return useStore((s) => s.transform[2] < LOD_ZOOM_THRESHOLD); } diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 64a0bea..40f29bb 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -360,6 +360,16 @@ body, box-shadow: 0 0 0 1px var(--gl-surface-border-strong), var(--gl-shadow-3); } +/* UI-perf fix: while a node is actively being dragged, React Flow's + controlled-node round-trip lag can put the cursor outside the card's + true rendered rect for a frame, toggling hover on/off and replaying + the border-color/box-shadow transition above as visible flicker. Pin + hover back to the resting appearance for the drag's duration. */ +.react-flow__node.dragging .scene-node:hover { + border-color: var(--gl-surface-border); + box-shadow: var(--gl-shadow-1); +} + /* ADR-012 stage 12.3: the actually-tabbable element for a node is React Flow's OWN wrapper div (.react-flow__node, one level up from .scene-node - see NodeMenu.tsx's own stage-12.3 doc for why), not .scene-node itself, so