Skip to content

Commit a57c916

Browse files
committed
perf(canvas): improve overall CPU usage
Reduce unnecessary computations impacting performance.
1 parent d57847c commit a57c916

6 files changed

Lines changed: 279 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1414
### Fixed
1515

1616
- Fixed all remaining Row-Level Security (RLS) issues, including critical failures when running on PostgreSQL 18. These fixes pave the way for migrating Ideon's officially supported PostgreSQL version from 16 to 18.
17+
- Improved overall CPU usage when working inside the canvas.
1718

1819
## [0.5.2] - 2026-03-02
1920

src/app/components/project/ProjectCanvas.tsx

Lines changed: 146 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
Background,
1010
BackgroundVariant,
1111
ConnectionMode,
12-
useViewport,
12+
useReactFlow as useReactFlowHook,
1313
Node,
1414
Edge,
1515
} from "@xyflow/react";
@@ -48,6 +48,7 @@ import {
4848
useLayoutEffect,
4949
useRef,
5050
useCallback,
51+
memo,
5152
} from "react";
5253
import {
5354
Plus,
@@ -82,62 +83,151 @@ const FIXED_EXTENT: [[number, number], [number, number]] = [
8283
];
8384
import { ProjectCanvasProps } from "./utils/types";
8485
import { UserMapProvider } from "./UserMapContext";
85-
86-
const RemoteCursors = ({
87-
activeUsers,
86+
import type { CursorPosition } from "./hooks/useProjectCanvasRealtime";
87+
88+
/**
89+
* Imperative remote cursors — bypasses React rendering entirely.
90+
* Reads cursor positions from a ref (updated by awareness) and viewport
91+
* from useReactFlow().getViewport() inside a rAF loop. Uses JS lerp
92+
* interpolation for smooth cursor movement (no CSS transitions).
93+
*/
94+
/** Ease-out cursor interpolation: fast catch-up, gentle braking */
95+
const LERP_MIN = 0.12;
96+
const LERP_MAX = 0.45;
97+
const LERP_DIST_SCALE = 200;
98+
const SNAP_THRESHOLD = 0.3;
99+
100+
const RemoteCursors = memo(function RemoteCursors({
101+
presenceUsers,
88102
currentUserId,
103+
remoteCursorsRef,
89104
}: {
90-
activeUsers: UserPresence[];
105+
presenceUsers: UserPresence[];
91106
currentUserId?: string;
92-
}) => {
93-
const { x: vX, y: vY, zoom } = useViewport();
107+
remoteCursorsRef: React.RefObject<Map<string, CursorPosition>>;
108+
}) {
109+
const { getViewport } = useReactFlowHook();
110+
const cursorElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
111+
const displayedRef = useRef<Map<string, { x: number; y: number }>>(new Map());
112+
const rafRef = useRef<number | null>(null);
113+
114+
const remoteUsers = useMemo(
115+
() => presenceUsers.filter((u) => u.id !== currentUserId),
116+
[presenceUsers, currentUserId],
117+
);
118+
119+
const setCursorRef = useCallback(
120+
(userId: string, el: HTMLDivElement | null) => {
121+
if (el) cursorElsRef.current.set(userId, el);
122+
else {
123+
cursorElsRef.current.delete(userId);
124+
displayedRef.current.delete(userId);
125+
}
126+
},
127+
[],
128+
);
129+
130+
useEffect(() => {
131+
if (remoteUsers.length === 0) {
132+
rafRef.current = null;
133+
return;
134+
}
135+
136+
let running = true;
137+
138+
const animate = () => {
139+
if (!running) return;
140+
const vp = getViewport();
141+
const cursors = remoteCursorsRef.current;
142+
143+
cursorElsRef.current.forEach((el, userId) => {
144+
const pos = cursors?.get(userId);
145+
if (!pos) {
146+
el.style.display = "none";
147+
return;
148+
}
94149

95-
if (!activeUsers) return null;
150+
const targetX = pos.x * vp.zoom + vp.x;
151+
const targetY = pos.y * vp.zoom + vp.y;
152+
if (isNaN(targetX) || isNaN(targetY)) {
153+
el.style.display = "none";
154+
return;
155+
}
156+
157+
let displayed = displayedRef.current.get(userId);
158+
if (!displayed) {
159+
displayed = { x: targetX, y: targetY };
160+
displayedRef.current.set(userId, displayed);
161+
}
162+
163+
const dx = targetX - displayed.x;
164+
const dy = targetY - displayed.y;
165+
const dist = Math.sqrt(dx * dx + dy * dy);
166+
167+
if (dist < SNAP_THRESHOLD) {
168+
displayed.x = targetX;
169+
displayed.y = targetY;
170+
} else {
171+
// Distance-based lerp: fast when far, slow braking when close
172+
const t =
173+
LERP_MIN +
174+
(LERP_MAX - LERP_MIN) * Math.min(1, dist / LERP_DIST_SCALE);
175+
displayed.x += dx * t;
176+
displayed.y += dy * t;
177+
}
178+
179+
el.style.display = "";
180+
el.style.transform = `translate3d(${displayed.x}px, ${displayed.y}px, 0)`;
181+
});
182+
183+
rafRef.current = requestAnimationFrame(animate);
184+
};
185+
186+
rafRef.current = requestAnimationFrame(animate);
187+
return () => {
188+
running = false;
189+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
190+
};
191+
}, [remoteUsers, getViewport, remoteCursorsRef]);
192+
193+
if (remoteUsers.length === 0) return null;
96194

97195
return (
98196
<>
99-
{activeUsers.map((user) => {
100-
if (!user.cursor || user.id === currentUserId) return null;
101-
const x = user.cursor.x * zoom + vX;
102-
const y = user.cursor.y * zoom + vY;
103-
104-
if (isNaN(x) || isNaN(y)) return null;
105-
106-
return (
107-
<div
108-
key={user.id}
109-
className="remote-cursor"
110-
style={
111-
{
112-
"--cursor-x": `${x}px`,
113-
"--cursor-y": `${y}px`,
114-
"--user-color": user.color || "#000",
115-
} as React.CSSProperties
116-
}
117-
>
118-
{!user.isTyping && (
119-
<>
120-
<svg
121-
width="24"
122-
height="24"
123-
viewBox="0 0 24 24"
124-
fill="none"
125-
xmlns="http://www.w3.org/2000/svg"
126-
className="remote-cursor-svg"
127-
>
128-
<path d="M0 0L14 14L7 14L0 21V0Z" fill="currentColor" />
129-
</svg>
130-
<div className="remote-cursor-name">
131-
{user.displayName || user.username}
132-
</div>
133-
</>
134-
)}
135-
</div>
136-
);
137-
})}
197+
{remoteUsers.map((user) => (
198+
<div
199+
key={user.id}
200+
ref={(el) => setCursorRef(user.id, el)}
201+
className="remote-cursor"
202+
style={
203+
{
204+
display: "none",
205+
"--user-color": user.color || "#000",
206+
} as React.CSSProperties
207+
}
208+
>
209+
{!user.isTyping && (
210+
<>
211+
<svg
212+
width="24"
213+
height="24"
214+
viewBox="0 0 24 24"
215+
fill="none"
216+
xmlns="http://www.w3.org/2000/svg"
217+
className="remote-cursor-svg"
218+
>
219+
<path d="M0 0L14 14L7 14L0 21V0Z" fill="currentColor" />
220+
</svg>
221+
<div className="remote-cursor-name">
222+
{user.displayName || user.username}
223+
</div>
224+
</>
225+
)}
226+
</div>
227+
))}
138228
</>
139229
);
140-
};
230+
});
141231

142232
const blockTypes = {
143233
text: CanvasBlock,
@@ -332,7 +422,8 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
332422
onBlockClick,
333423
onLinkClick,
334424
handleCreateBlock,
335-
activeUsers,
425+
remoteCursorsRef,
426+
presenceUsers,
336427
shareCursor,
337428
setShareCursor,
338429
projectOwnerId,
@@ -598,8 +689,8 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
598689

599690
const isTyping = useMemo(() => {
600691
if (!currentUser) return false;
601-
return activeUsers.some((u) => u.id === currentUser.id && u.isTyping);
602-
}, [activeUsers, currentUser]);
692+
return presenceUsers.some((u) => u.id === currentUser.id && u.isTyping);
693+
}, [presenceUsers, currentUser]);
603694

604695
const contextMenuRef = useRef<HTMLDivElement>(null);
605696

@@ -702,7 +793,7 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
702793
</div>
703794
)}
704795

705-
<UserMapProvider activeUsers={activeUsers}>
796+
<UserMapProvider activeUsers={presenceUsers}>
706797
<ReactFlow
707798
nodes={blocksWithPreview}
708799
edges={links}
@@ -757,8 +848,9 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
757848
style={{ width: "100%", height: "100%", zIndex: 1500 }}
758849
>
759850
<RemoteCursors
760-
activeUsers={activeUsers}
851+
presenceUsers={presenceUsers}
761852
currentUserId={currentUser?.id}
853+
remoteCursorsRef={remoteCursorsRef}
762854
/>
763855
</Panel>
764856
<Background
@@ -871,7 +963,7 @@ function ProjectCanvasContent({ initialProjectId }: ProjectCanvasProps) {
871963
<div className="flex items-center gap-2">
872964
{!isPreviewMode && (
873965
<div className="flex gap-2 mr-2">
874-
{activeUsers.map((u) => (
966+
{presenceUsers.map((u) => (
875967
<div
876968
key={u.id}
877969
className="user-presence-item relative shrink-0"

0 commit comments

Comments
 (0)