Skip to content

Commit 115ca2f

Browse files
committed
Add unified useAwareness() hook with simplified API
Implement a new unified useAwareness() hook that consolidates useRemoteUsers(), useLiveRemoteUsers(), and useUserCursors() into a single flexible API. Key changes: - Remove excludeLocal option - hook always excludes local user - Support cached mode for smooth avatar transitions (60s TTL) - Support map format for efficient Monaco cursor CSS generation - Add comprehensive test coverage (26 tests) API: - useAwareness() - live remote users (for cursors) - useAwareness({ cached: true }) - cached remote users (for avatars) - useAwareness({ format: 'map' }) - Map format (for Monaco CSS) Legacy hooks remain available but are marked as deprecated. Migrate components to unified useAwareness() API Migrate all collaborative editor components to use the new simplified useAwareness() hook and update test mocks. Component migrations: - ActiveCollaborators: useRemoteUsers() → useAwareness({ cached: true }) - Cursors: useUserCursors() → useAwareness({ format: 'map' }) - CollaborationWidget: useAwarenessUsers() → useAwareness({ cached: true }) - RemoteCursor: useRemoteUsers() → useAwareness() Test updates: - Update mocks to use useAwareness instead of legacy hooks UX improvements: - CollaborationWidget now shows "You + N others" for clarity - RemoteCursor bug fix: cursors now disappear immediately on disconnect Optimize remote cursor rendering to prevent jumpy movement Fixes browser-specific issue where remote cursors appeared jumpy when viewport updates were frequent (during panning/zooming). The problem was render storms interrupting CSS transitions. Changes: - Add position rounding to reduce micro-pixel updates - Replace left/top positioning with CSS transform for better GPU acceleration - Wrap RemoteCursor in React.memo with custom comparison to skip re-renders when position changes are <1px This prevents frequent viewport updates from recreating cursor elements and interrupting their CSS transitions.
1 parent 1ae1aae commit 115ca2f

File tree

9 files changed

+1618
-76
lines changed

9 files changed

+1618
-76
lines changed

assets/js/collaborative-editor/components/ActiveCollaborators.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cn } from '../../utils/cn';
2-
import { useRemoteUsers } from '../hooks/useAwareness';
2+
import { useAwareness } from '../hooks/useAwareness';
33
import { getAvatarInitials } from '../utils/avatar';
44

55
import { Tooltip } from './Tooltip';
@@ -15,7 +15,7 @@ interface ActiveCollaboratorsProps {
1515
}
1616

1717
export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
18-
const remoteUsers = useRemoteUsers();
18+
const remoteUsers = useAwareness({ cached: true });
1919

2020
if (remoteUsers.length === 0) {
2121
return null;

assets/js/collaborative-editor/components/CollaborationWidget.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55

66
import { useSocket } from '../../react/contexts/SocketProvider';
77
import { cn } from '../../utils/cn';
8-
import { useAwarenessUsers } from '../hooks/useAwareness';
8+
import { useAwareness } from '../hooks/useAwareness';
99
import { useSession } from '../hooks/useSession';
1010

1111
export function CollaborationWidget() {
1212
const { isConnected: socketConnected, connectionError } = useSocket();
1313
const { isConnected: yjsConnected, isSynced } = useSession();
1414

15-
const users = useAwarenessUsers();
15+
// Get remote users only (local user is always excluded)
16+
const remoteUsers = useAwareness({ cached: true });
1617

1718
const getStatusColor = () => {
1819
if (socketConnected && yjsConnected && isSynced) return 'bg-green-500';
@@ -40,19 +41,20 @@ export function CollaborationWidget() {
4041
</div>
4142

4243
{/* Separator */}
43-
{users.length > 0 && <div className="w-px h-3 bg-gray-300" />}
44+
{remoteUsers.length > 0 && <div className="w-px h-3 bg-gray-300" />}
4445

4546
{/* Online users */}
46-
{users.length > 0 && (
47+
{remoteUsers.length > 0 && (
4748
<div className="flex items-center gap-1">
4849
<span className="text-gray-500">
49-
{users.length} user{users.length !== 1 ? 's' : ''}:
50+
You + {remoteUsers.length} other
51+
{remoteUsers.length !== 1 ? 's' : ''}:
5052
</span>
5153
<div className="flex gap-1">
52-
{users.slice(0, 3).map(user => (
54+
{remoteUsers.slice(0, 3).map(user => (
5355
<div
5456
key={user.clientId}
55-
className="flex items-center gap-1 px-2 py-0.5
57+
className="flex items-center gap-1 px-2 py-0.5
5658
bg-gray-50 rounded-full"
5759
title={`${user.user.name} (Client ${user.clientId})`}
5860
>
@@ -65,9 +67,9 @@ export function CollaborationWidget() {
6567
</span>
6668
</div>
6769
))}
68-
{users.length > 3 && (
70+
{remoteUsers.length > 3 && (
6971
<span className="text-gray-400 px-1">
70-
+{users.length - 3} more
72+
+{remoteUsers.length - 3} more
7173
</span>
7274
)}
7375
</div>

assets/js/collaborative-editor/components/Cursors.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo } from 'react';
22

3-
import { useUserCursors, useRemoteUsers } from '../hooks/useAwareness';
3+
import { useAwareness } from '../hooks/useAwareness';
44

55
function BaseStyles() {
66
const baseStyles = `
@@ -46,16 +46,14 @@ function BaseStyles() {
4646
* Cursors component using awareness hooks for better performance and maintainability
4747
*
4848
* Key improvements:
49-
* - Uses useUserCursors() hook with memoized Map for efficient lookups
50-
* - Uses useRemoteUsers() for selection data (referentially stable)
49+
* - Uses useAwareness() hook with Map format for efficient clientId lookups
50+
* - Returns referentially stable data that only changes when users change
5151
* - Eliminates manual awareness state management and reduces re-renders
5252
*/
5353
export function Cursors() {
5454
// Get cursor data as a Map for efficient clientId lookups
55-
const cursorsMap = useUserCursors();
56-
57-
// Get remote users for selection data
58-
const remoteUsers = useRemoteUsers();
55+
// Note: Uses live users only (not cached), always excludes local user
56+
const cursorsMap = useAwareness({ format: 'map' });
5957

6058
// Dynamic user-specific cursor styles - now using Map entries
6159
const userStyles = useMemo(() => {
@@ -127,7 +125,7 @@ export function Cursors() {
127125
return () => {
128126
editorElement?.removeEventListener('scroll', checkCursorPositions);
129127
};
130-
}, [remoteUsers.length]); // Only re-run when remote users change
128+
}, [cursorsMap.size]); // Only re-run when live users change
131129

132130
return (
133131
<>
Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useViewport } from '@xyflow/react';
2-
import { useMemo } from 'react';
2+
import { memo, useMemo } from 'react';
33

44
import { cn } from '../../../utils/cn';
5-
import { useRemoteUsers } from '../../hooks/useAwareness';
5+
import { useAwareness } from '../../hooks/useAwareness';
66

77
import { denormalizePointerPosition } from './normalizePointer';
88

@@ -15,7 +15,7 @@ interface RemoteCursor {
1515
}
1616

1717
export function RemoteCursors() {
18-
const remoteUsers = useRemoteUsers();
18+
const remoteUsers = useAwareness();
1919
const { x: tx, y: ty, zoom: tzoom } = useViewport();
2020

2121
const cursors = useMemo<RemoteCursor[]>(() => {
@@ -30,12 +30,14 @@ export function RemoteCursors() {
3030
[tx, ty, tzoom]
3131
);
3232

33+
// Round to nearest pixel to reduce micro-updates
34+
// that trigger unnecessary re-renders
3335
return {
3436
clientId: user.clientId,
3537
name: user.user.name,
3638
color: user.user.color,
37-
x: screenPos.x,
38-
y: screenPos.y,
39+
x: Math.round(screenPos.x),
40+
y: Math.round(screenPos.y),
3941
};
4042
});
4143
}, [remoteUsers, tx, ty, tzoom]);
@@ -66,42 +68,57 @@ interface RemoteCursorProps {
6668
y: number;
6769
}
6870

69-
function RemoteCursor({ name, color, x, y }: RemoteCursorProps) {
70-
return (
71-
<div
72-
className="absolute transition-all duration-100 ease-out"
73-
style={{
74-
left: `${x}px`,
75-
top: `${y}px`,
76-
transform: 'translate(-2px, -2px)', // small shift to align pointer well
77-
}}
78-
>
79-
{/* Cursor pointer (SVG arrow) */}
80-
<svg
81-
width="24"
82-
height="24"
83-
viewBox="0 0 24 24"
84-
fill="none"
85-
xmlns="http://www.w3.org/2000/svg"
86-
className="drop-shadow-md"
87-
>
88-
<path
89-
d="M 4.580503,0.60395675 A 2,2 0 0 1 6.850503,0.18395675 V 0.18395675 L 22.780503,7.4039568 A 2,2 0 0 1 23.980503,9.5239568 A 2.26,2.26 0 0 1 22.180503,11.523957 L 16.600503,12.653957 L 15.470503,18.233957 A 2.26,2.26 0 0 1 13.470503,20.033957 H 13.220503 A 2,2 0 0 1 11.350503,18.833957 L 4.160503,2.8739568 A 2,2 0 0 1 4.580503,0.60395675 Z"
90-
fill={color}
91-
stroke="white"
92-
strokeWidth="1.5"
93-
/>
94-
</svg>
95-
{/* User name label */}
71+
// Memoize RemoteCursor to prevent re-renders when position hasn't changed
72+
// significantly. This prevents CSS transitions from being interrupted by
73+
// frequent viewport updates.
74+
const RemoteCursor = memo(
75+
function RemoteCursor({ name, color, x, y }: RemoteCursorProps) {
76+
return (
9677
<div
97-
className={cn(
98-
'absolute left-7 top-0 whitespace-nowrap rounded px-2 py-1',
99-
'text-xs font-medium text-white shadow-md'
100-
)}
101-
style={{ backgroundColor: color }}
78+
className="absolute transition-all duration-100 ease-out"
79+
style={{
80+
// Use transform for better GPU acceleration and smoother transitions
81+
// Translate by (x-2, y-2) to align pointer well
82+
transform: `translate(${x - 2}px, ${y - 2}px)`,
83+
}}
10284
>
103-
{name}
85+
{/* Cursor pointer (SVG arrow) */}
86+
<svg
87+
width="24"
88+
height="24"
89+
viewBox="0 0 24 24"
90+
fill="none"
91+
xmlns="http://www.w3.org/2000/svg"
92+
className="drop-shadow-md"
93+
>
94+
<path
95+
d="M 4.580503,0.60395675 A 2,2 0 0 1 6.850503,0.18395675 V 0.18395675 L 22.780503,7.4039568 A 2,2 0 0 1 23.980503,9.5239568 A 2.26,2.26 0 0 1 22.180503,11.523957 L 16.600503,12.653957 L 15.470503,18.233957 A 2.26,2.26 0 0 1 13.470503,20.033957 H 13.220503 A 2,2 0 0 1 11.350503,18.833957 L 4.160503,2.8739568 A 2,2 0 0 1 4.580503,0.60395675 Z"
96+
fill={color}
97+
stroke="white"
98+
strokeWidth="1.5"
99+
/>
100+
</svg>
101+
{/* User name label */}
102+
<div
103+
className={cn(
104+
'absolute left-7 top-0 whitespace-nowrap rounded px-2 py-1',
105+
'text-xs font-medium text-white shadow-md'
106+
)}
107+
style={{ backgroundColor: color }}
108+
>
109+
{name}
110+
</div>
104111
</div>
105-
</div>
106-
);
107-
}
112+
);
113+
},
114+
// Custom comparison: only re-render if position changed by >1px
115+
// This prevents render storms from micro viewport changes
116+
(prev, next) => {
117+
return (
118+
prev.name === next.name &&
119+
prev.color === next.color &&
120+
Math.abs(prev.x - next.x) < 1 &&
121+
Math.abs(prev.y - next.y) < 1
122+
);
123+
}
124+
);

0 commit comments

Comments
 (0)