Skip to content

Commit 7ead8b2

Browse files
committed
perf(realtime): move presence state out of the socket context to stop cursor-frame re-renders
1 parent 66ba264 commit 7ead8b2

5 files changed

Lines changed: 79 additions & 20 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useViewport } from 'reactflow'
55
import { getUserColor } from '@/lib/workspaces/colors'
66
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
77
import { useSocket } from '@/app/workspace/providers/socket-provider'
8+
import { usePresenceStore } from '@/stores/presence/store'
89
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
910

1011
interface CursorPoint {
@@ -21,7 +22,8 @@ interface CursorRenderData {
2122

2223
const CursorsComponent = () => {
2324
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
24-
const { currentWorkflowId, presenceUsers, currentSocketId } = useSocket()
25+
const { currentWorkflowId, currentSocketId } = useSocket()
26+
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
2527
const viewport = useViewport()
2628
const preventZoomRef = usePreventZoom()
2729

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
55
import { getUserColor } from '@/lib/workspaces/colors'
66
import { useSocket } from '@/app/workspace/providers/socket-provider'
77
import { SIDEBAR_WIDTH } from '@/stores/constants'
8+
import { usePresenceStore } from '@/stores/presence/store'
89
import { useSidebarStore } from '@/stores/sidebar/store'
910

1011
/**
@@ -80,7 +81,8 @@ function UserAvatar({ user, index }: UserAvatarProps) {
8081
* @returns Avatar stack for workflow presence
8182
*/
8283
export function Avatars({ workflowId }: AvatarsProps) {
83-
const { presenceUsers, currentWorkflowId, currentSocketId } = useSocket()
84+
const { currentWorkflowId, currentSocketId } = useSocket()
85+
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
8486
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
8587

8688
/**

apps/sim/app/workspace/providers/socket-provider.tsx

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import type {
4343
VariableUpdateEmit,
4444
WorkflowOperationEmit,
4545
} from '@/stores/operation-queue/types'
46+
import { usePresenceStore } from '@/stores/presence/store'
47+
import type { PresenceUser } from '@/stores/presence/types'
4648
import { useWorkflowRegistry as useWorkflowRegistryStore } from '@/stores/workflows/registry/store'
4749

4850
const logger = createLogger('SocketContext')
@@ -71,15 +73,6 @@ interface User {
7173
email?: string
7274
}
7375

74-
interface PresenceUser {
75-
socketId: string
76-
userId: string
77-
userName: string
78-
avatarUrl?: string | null
79-
cursor?: { x: number; y: number } | null
80-
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
81-
}
82-
8376
interface SocketContextType {
8477
socket: Socket | null
8578
isConnected: boolean
@@ -95,7 +88,6 @@ interface SocketContextType {
9588
blockedJoinWorkflowId: string | null
9689
currentWorkflowId: string | null
9790
currentSocketId: string | null
98-
presenceUsers: PresenceUser[]
9991
joinWorkflow: (workflowId: string) => void
10092
leaveWorkflow: () => void
10193
retryConnection: () => void
@@ -129,7 +121,6 @@ const SocketContext = createContext<SocketContextType>({
129121
blockedJoinWorkflowId: null,
130122
currentWorkflowId: null,
131123
currentSocketId: null,
132-
presenceUsers: [],
133124
joinWorkflow: () => {},
134125
leaveWorkflow: () => {},
135126
retryConnection: () => {},
@@ -166,7 +157,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
166157
const [isRetryingWorkflowJoin, setIsRetryingWorkflowJoin] = useState(false)
167158
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
168159
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
169-
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
170160
const [authFailed, setAuthFailed] = useState(false)
171161
const [blockedJoinWorkflowId, setBlockedJoinWorkflowId] = useState<string | null>(null)
172162
const [explicitWorkflowId, setExplicitWorkflowId] = useState<string | null>(null)
@@ -202,6 +192,21 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
202192
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
203193
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
204194

195+
/**
196+
* Presence is high-frequency (cursor frames many times per second) so it lives
197+
* in {@link usePresenceStore}, not the broad socket context — writing it here no
198+
* longer mints a new context value, so emitter-only `useSocket()` consumers stop
199+
* re-rendering on every cursor frame. These thin wrappers delegate to the store's
200+
* stable actions read via `getState()`.
201+
*/
202+
const setPresenceUsers = useCallback((users: PresenceUser[]) => {
203+
usePresenceStore.getState().setPresenceUsers(users)
204+
}, [])
205+
206+
const updatePresenceUsers = useCallback((updater: (prev: PresenceUser[]) => PresenceUser[]) => {
207+
usePresenceStore.getState().updatePresenceUsers(updater)
208+
}, [])
209+
205210
const setVisibleWorkflowId = useCallback((workflowId: string | null) => {
206211
currentWorkflowIdRef.current = workflowId
207212
setCurrentWorkflowId(workflowId)
@@ -255,7 +260,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
255260
setPresenceUsers([])
256261
setVisibleWorkflowId(null)
257262
},
258-
[resetVisibleWorkflowState, setVisibleWorkflowId]
263+
[resetVisibleWorkflowState, setPresenceUsers, setVisibleWorkflowId]
259264
)
260265

261266
const executeJoinCommands = useCallback(
@@ -501,7 +506,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
501506
return
502507
}
503508

504-
setPresenceUsers((prev) => {
509+
updatePresenceUsers((prev) => {
505510
const prevMap = new Map(prev.map((u) => [u.socketId, u]))
506511

507512
return users.map((user) => {
@@ -675,7 +680,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
675680
return
676681
}
677682

678-
setPresenceUsers((prev) => {
683+
updatePresenceUsers((prev) => {
679684
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
680685
if (existingIndex === -1) {
681686
logger.debug('Received cursor-update for unknown user', { socketId: data.socketId })
@@ -693,7 +698,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
693698
return
694699
}
695700

696-
setPresenceUsers((prev) => {
701+
updatePresenceUsers((prev) => {
697702
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
698703
if (existingIndex === -1) {
699704
logger.debug('Received selection-update for unknown user', {
@@ -779,6 +784,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
779784
socketRef.current.close()
780785
socketRef.current = null
781786
}
787+
788+
// Presence lives in a module-global store (not provider React state), so it
789+
// must be cleared explicitly on unmount/user-switch to match the prior
790+
// per-provider lifetime — otherwise the previous room's collaborators linger.
791+
usePresenceStore.getState().clearPresenceUsers()
782792
}
783793
}, [user?.id])
784794

@@ -1116,7 +1126,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
11161126
blockedJoinWorkflowId,
11171127
currentWorkflowId,
11181128
currentSocketId,
1119-
presenceUsers,
11201129
joinWorkflow,
11211130
leaveWorkflow,
11221131
retryConnection,
@@ -1147,7 +1156,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
11471156
blockedJoinWorkflowId,
11481157
currentWorkflowId,
11491158
currentSocketId,
1150-
presenceUsers,
11511159
joinWorkflow,
11521160
leaveWorkflow,
11531161
retryConnection,

apps/sim/stores/presence/store.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { create } from 'zustand'
2+
import { devtools } from 'zustand/middleware'
3+
import type { PresenceState } from '@/stores/presence/types'
4+
5+
/**
6+
* Live collaborator presence for the active workflow room.
7+
*
8+
* Presence is high-frequency (cursor frames arrive many times per second), so it
9+
* lives in its own store rather than the broad socket context. Only presence
10+
* consumers (`<Cursors>`, `<Avatars>`) subscribe to it, so cursor frames no
11+
* longer re-render emitter-only `useSocket()` consumers such as `WorkflowContent`.
12+
*/
13+
export const usePresenceStore = create<PresenceState>()(
14+
devtools(
15+
(set) => ({
16+
presenceUsers: [],
17+
setPresenceUsers: (users) => set({ presenceUsers: users }),
18+
updatePresenceUsers: (updater) =>
19+
set((state) => ({ presenceUsers: updater(state.presenceUsers) })),
20+
clearPresenceUsers: () => set({ presenceUsers: [] }),
21+
}),
22+
{ name: 'presence-store' }
23+
)
24+
)

apps/sim/stores/presence/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* A collaborator present in the active workflow room. Mirrors the presence
3+
* payload broadcast by the realtime server (`presence-update`, cursor/selection
4+
* deltas, and `join-workflow-success`).
5+
*/
6+
export interface PresenceUser {
7+
socketId: string
8+
userId: string
9+
userName: string
10+
avatarUrl?: string | null
11+
cursor?: { x: number; y: number } | null
12+
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
13+
}
14+
15+
export interface PresenceState {
16+
presenceUsers: PresenceUser[]
17+
/** Replace the full presence list (join success, presence-update). */
18+
setPresenceUsers: (users: PresenceUser[]) => void
19+
/** Apply a functional update to the presence list (cursor/selection deltas). */
20+
updatePresenceUsers: (updater: (prev: PresenceUser[]) => PresenceUser[]) => void
21+
/** Clear presence when leaving or losing the workflow room. */
22+
clearPresenceUsers: () => void
23+
}

0 commit comments

Comments
 (0)