perf(frontend): bound logs DOM, kill editor re-render storms, lazy-load heavy deps (#5212)

* perf(logs): virtualize the resource list to bound DOM + memory

* perf(editor): narrow per-block store subscription to kill structural-edit re-render storm

* perf(realtime): move presence state out of the socket context to stop cursor-frame re-renders

* perf(editor): lazy-load NoteBlock so Streamdown is off the editor's critical path

* perf(emcn): defer prismjs in Code so it's off the shared barrel's static graph
This commit is contained in:
Waleed
2026-06-25 13:58:51 -07:00
committed by GitHub
parent 34d32b97dd
commit 9e1a4ac5d1
15 changed files with 515 additions and 130 deletions
@@ -31,6 +31,7 @@ export type {
ResourceCellEditing,
ResourceColumn,
ResourceRow,
ResourceTableHandle,
RowDragDropConfig,
SelectableConfig,
} from './resource/resource'
@@ -1,13 +1,18 @@
'use client'
import {
type CSSProperties,
type DragEvent,
memo,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
Button,
@@ -74,11 +79,11 @@ export interface RowDragDropConfig {
isAnyDragActive?: boolean
isRowDraggable?: (rowId: string) => boolean
isRowDropTarget?: (rowId: string) => boolean
onDragStart?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragOver?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragLeave?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDrop?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragEnd?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragStart?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragOver?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragLeave?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDrop?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragEnd?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
}
export interface PaginationConfig {
@@ -89,6 +94,34 @@ export interface PaginationConfig {
export const EMPTY_CELL_PLACEHOLDER = '—'
/**
* Seed height (px) for each virtualized row before it is measured. Every
* consumer renders single-line `py-2.5` cells, so this matches the resting row
* height closely; `measureElement` then corrects each row to its exact pixel
* height after mount, so the estimate only affects pre-measure scroll math.
*/
const ROW_HEIGHT_ESTIMATE = 41 as const
/** Rows rendered above/below the viewport to avoid blank flashes on fast scroll. */
const ROW_OVERSCAN = 8 as const
const CHECKBOX_COLUMN_WIDTH = '52px'
/**
* Builds the shared CSS grid track list for the header and every body row from
* the same first-column-weighted ratios the legacy `<colgroup>` used, so the
* virtualized grid layout reproduces the exact column widths. The checkbox
* column, when present, is a fixed leading track.
*/
function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string {
const weights = columns.map(
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
)
const total = weights.reduce((s, w) => s + w, 0)
const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`)
return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ')
}
interface ResourceProps {
children: ReactNode
onContextMenu?: (e: React.MouseEvent) => void
@@ -123,10 +156,29 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) {
)
}
/**
* Imperative handle for `Resource.Table`. Lets a consumer drive virtualizer-aware
* scrolling — required for keyboard navigation, since a `querySelector` on the
* selected row's DOM node silently no-ops once that row is windowed out.
*/
export interface ResourceTableHandle {
/** Scroll the row with the given id into view via the virtualizer (works even when the row is not in the DOM). */
scrollToRow: (rowId: string) => void
}
interface ResourceTableProps {
columns: ResourceColumn[]
rows: ResourceRow[]
selectedRowId?: string | null
/** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */
apiRef?: RefObject<ResourceTableHandle | null>
/**
* Window the row list with `@tanstack/react-virtual`, keeping only the visible
* slice in the DOM. Opt-in because it removes off-screen rows — consumers that
* depend on every row being mounted (e.g. drag-and-drop drop targets) must stay
* on the full-DOM path. Enable it only for unbounded, accumulating lists (logs).
*/
virtualized?: boolean
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
@@ -148,16 +200,28 @@ interface ResourceTableProps {
* Data table body, module-private and exposed only as `Resource.Table` — the
* compound member is the sole way consumers render it.
*
* Chrome guarantee: the `<table>`, `<colgroup>`, and column headers render
* unconditionally — no prop or row state (empty, loading, error) ever drops
* them. Structural additions (checkbox column, load-more sentinel, pagination
* bar) are driven purely by which configs the consumer supplies and always
* render the canonical chrome.
* Chrome guarantee: the table region and column headers render unconditionally —
* no prop or row state (empty, loading, error) ever drops them. Structural
* additions (checkbox column, load-more sentinel, pagination bar) are driven
* purely by which configs the consumer supplies and always render the canonical
* chrome.
*
* The table is built from `<div>`s carrying explicit ARIA roles (`table`,
* `rowgroup`, `row`, `columnheader`, `cell`) rather than native table elements:
* the rows use CSS grid for column alignment, and `display: grid` on a native
* `<table>` strips its implicit table semantics, so the roles are declared
* directly. Column widths come from a shared grid track list (see
* {@link buildGridTemplateColumns}) reproducing the legacy `<colgroup>` ratios.
* When `virtualized`, the body windows with `@tanstack/react-virtual` so only
* the visible row slice is in the DOM, bounding DOM size and memory on lists
* that accumulate many pages.
*/
const ResourceTable = memo(function ResourceTable({
columns,
rows,
selectedRowId,
apiRef,
virtualized = false,
selectable,
rowDragDrop,
onRowClick,
@@ -169,6 +233,7 @@ const ResourceTable = memo(function ResourceTable({
pagination,
overlay,
}: ResourceTableProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
@@ -224,15 +289,53 @@ const ResourceTable = memo(function ResourceTable({
[selectable]
)
const gridTemplateColumns = useMemo(
() => buildGridTemplateColumns(columns, hasCheckbox),
[columns, hasCheckbox]
)
/**
* Windows the row list so only the visible slice (plus overscan) is in the
* DOM, bounding DOM size and memory regardless of how many pages a consumer
* accumulates. Rows are measured via {@link rowVirtualizer.measureElement} so
* any single-line height variance stays pixel-exact.
*/
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT_ESTIMATE,
overscan: ROW_OVERSCAN,
getItemKey: (index) => rows[index].id,
})
useImperativeHandle(
apiRef,
() => ({
scrollToRow: (rowId: string) => {
const index = rows.findIndex((row) => row.id === rowId)
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: 'auto' })
},
}),
[rows, rowVirtualizer]
)
const virtualRows = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<thead className='sticky top-0 z-10 bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
<div role='table' className='grid w-full text-small'>
<div
role='rowgroup'
className='sticky top-0 z-10 grid bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'
>
<div role='row' className='grid' style={{ gridTemplateColumns }}>
{hasCheckbox && (
<th className='h-10 w-[52px] py-1.5 pr-0 pl-5 text-left align-middle'>
<div
role='columnheader'
className='flex h-10 items-center py-1.5 pr-0 pl-5 text-left'
>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
@@ -240,36 +343,65 @@ const ResourceTable = memo(function ResourceTable({
disabled={selectable.disabled}
aria-label='Select all'
/>
</th>
</div>
)}
{columns.map((col) => (
<th
<div
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-[var(--text-muted)] text-small'
role='columnheader'
className='flex h-10 min-w-0 items-center px-6 py-1.5 text-left font-normal text-[var(--text-muted)] text-small'
>
{col.header}
</th>
<span className='min-w-0 truncate'>{col.header}</span>
</div>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
))}
</tbody>
</table>
</div>
</div>
<div
role='rowgroup'
className={cn('grid', virtualized && 'relative')}
style={virtualized ? { height: totalSize } : undefined}
>
{virtualized
? virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<DataRow
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
dataIndex={virtualRow.index}
translateY={virtualRow.start}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
)
})
: rows.map((row) => (
<DataRow
key={row.id}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
))}
</div>
</div>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-3'>
{isLoadingMore && (
@@ -393,6 +525,18 @@ interface DataRowProps {
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isContextMenuTarget?: boolean
hasCheckbox: boolean
/** CSS grid track list shared with the header so columns stay aligned. */
gridTemplateColumns: string
/**
* Virtual row offset. When set, the row is absolutely positioned within the
* sized tbody (windowed mode); when omitted, the row renders in normal grid
* flow (full-DOM mode).
*/
translateY?: number
/** Virtual index, consumed by the virtualizer's `measureElement` ref (windowed mode only). */
dataIndex?: number
/** Forwarded from the virtualizer so each mounted row is measured exactly (windowed mode only). */
ref?: (node: HTMLDivElement | null) => void
}
const DataRow = memo(function DataRow({
@@ -406,6 +550,10 @@ const DataRow = memo(function DataRow({
onRowContextMenu,
isContextMenuTarget,
hasCheckbox,
gridTemplateColumns,
translateY,
dataIndex,
ref,
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
@@ -416,7 +564,7 @@ const DataRow = memo(function DataRow({
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
const handleClick = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (
selectable &&
!selectable.disabled &&
@@ -457,32 +605,41 @@ const DataRow = memo(function DataRow({
[selectable, row.id]
)
const handleDragStart = (e: DragEvent<HTMLTableRowElement>) => {
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragStart?.(e, row.id)
}
const handleDragOver = (e: DragEvent<HTMLTableRowElement>) => {
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragOver?.(e, row.id)
}
const handleDragLeave = (e: DragEvent<HTMLTableRowElement>) => {
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragLeave?.(e, row.id)
}
const handleDrop = (e: DragEvent<HTMLTableRowElement>) => {
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDrop?.(e, row.id)
}
const handleDragEnd = (e: DragEvent<HTMLTableRowElement>) => {
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragEnd?.(e, row.id)
}
const isWindowed = translateY !== undefined
const rowStyle: CSSProperties = isWindowed
? { gridTemplateColumns, transform: `translateY(${translateY}px)` }
: { gridTemplateColumns }
return (
<tr
<div
ref={ref}
role='row'
data-index={dataIndex}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors',
'grid w-full transition-colors',
isWindowed && 'absolute top-0 left-0',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
isDraggable && 'cursor-grab active:cursor-grabbing',
@@ -491,6 +648,7 @@ const DataRow = memo(function DataRow({
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
style={rowStyle}
data-drop-target={isDropTarget || undefined}
draggable={isDraggable}
onClick={onRowClick || selectable ? handleClick : undefined}
@@ -503,7 +661,7 @@ const DataRow = memo(function DataRow({
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<div role='cell' className='flex items-center py-2.5 pr-0 pl-5'>
<Checkbox
size='sm'
checked={isSelected}
@@ -512,47 +670,22 @@ const DataRow = memo(function DataRow({
aria-label='Select row'
onClick={handleSelectRowClick}
/>
</td>
</div>
)}
{columns.map((col) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<div key={col.id} role='cell' className='flex min-w-0 items-center px-6 py-2.5'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
editing={cell?.editing}
/>
</td>
</div>
)
})}
</tr>
)
})
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
}
const CHECKBOX_COLUMN_WIDTH = '52px'
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
const weights = columns.map(
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
)
const total = weights.reduce((s, w) => s + w, 0)
return (
<colgroup>
{hasCheckbox && <col style={{ width: CHECKBOX_COLUMN_WIDTH }} />}
{columns.map((col, colIdx) => (
<col key={col.id} style={{ width: `${((weights[colIdx] / total) * 100).toFixed(3)}%` }} />
))}
</colgroup>
</div>
)
})
@@ -726,7 +726,7 @@ export function Files() {
isAnyDragActive: draggedRowIds.size > 0,
isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId,
isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder',
onDragStart: (e: DragEvent<HTMLTableRowElement>, rowId) => {
onDragStart: (e: DragEvent<HTMLDivElement>, rowId) => {
if (!canEdit || listRename.editingId === rowId) {
e.preventDefault()
return
@@ -769,7 +769,7 @@ export function Files() {
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
},
onDragOver: (e: DragEvent<HTMLTableRowElement>, rowId) => {
onDragOver: (e: DragEvent<HTMLDivElement>, rowId) => {
const sourceRowIds = draggedRowIdsRef.current
const isExternalFileDrag = hasExternalFiles(e.dataTransfer)
if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return
@@ -779,12 +779,12 @@ export function Files() {
e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move'
setActiveDropTargetId(rowId)
},
onDragLeave: (e: DragEvent<HTMLTableRowElement>, rowId) => {
onDragLeave: (e: DragEvent<HTMLDivElement>, rowId) => {
const relatedTarget = e.relatedTarget
if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return
setActiveDropTargetId((current) => (current === rowId ? null : current))
},
onDrop: (e: DragEvent<HTMLTableRowElement>, rowId) => {
onDrop: (e: DragEvent<HTMLDivElement>, rowId) => {
e.preventDefault()
e.stopPropagation()
dragCounterRef.current = 0
@@ -51,7 +51,7 @@ import type {
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components'
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import {
@@ -262,6 +262,7 @@ export default function Logs() {
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const shouldScrollIntoViewRef = useRef(false)
const resourceTableRef = useRef<ResourceTableHandle>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const activeLogTabRef = useRef<string>('overview')
@@ -565,10 +566,8 @@ export default function Logs() {
useEffect(() => {
if (!selectedLogId || !shouldScrollIntoViewRef.current) return
shouldScrollIntoViewRef.current = false
const row = document.querySelector(`[data-row-id="${selectedLogId}"]`) as HTMLElement | null
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
// Route through the virtualizer; a querySelector would miss windowed-out rows.
resourceTableRef.current?.scrollToRow(selectedLogId)
}, [selectedLogId, selectedLogIndex])
const effectiveSidebarOpen =
@@ -1122,6 +1121,8 @@ export default function Logs() {
</div>
) : (
<Resource.Table
apiRef={resourceTableRef}
virtualized
columns={LOG_COLUMNS}
rows={rows}
selectedRowId={selectedLogId}
@@ -5,6 +5,7 @@ import { useViewport } from 'reactflow'
import { getUserColor } from '@/lib/workspaces/colors'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { usePresenceStore } from '@/stores/presence/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CursorPoint {
@@ -21,7 +22,8 @@ interface CursorRenderData {
const CursorsComponent = () => {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { currentWorkflowId, presenceUsers, currentSocketId } = useSocket()
const { currentWorkflowId, currentSocketId } = useSocket()
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
const viewport = useViewport()
const preventZoomRef = usePreventZoom()
@@ -0,0 +1,66 @@
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { CurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Per-block, narrowly-subscribed variant of {@link useCurrentWorkflow}.
*
* `useCurrentWorkflow` subscribes (via `useShallow`) to the entire
* `{ blocks, edges, loops, parallels, lastSaved }` slice, so any structural
* edit that replaces the `blocks` reference (rename, lock/enable toggle,
* dimension settle, add/remove, paste) re-renders every mounted block at once.
*
* This hook returns the same {@link CurrentWorkflow} shape but subscribes only
* to the fields a single block actually reads: its own block object and the
* diff-mode flags. The `blocks` map it exposes contains only this block — every
* consumer (`useBlockState`, `useBlockProperties`, `workflow-block.tsx`) only
* ever indexes the map at `blockId`, so the narrowed map is behaviorally
* identical while staying reference-stable across edits to other blocks.
*
* @param blockId - The block whose view of the workflow is needed
* @returns A {@link CurrentWorkflow} scoped to the given block
*/
export function useBlockCurrentWorkflow(blockId: string): CurrentWorkflow {
const normalBlock = useWorkflowStore((state) => state.blocks[blockId])
const { isShowingDiff, isDiffReady, hasActiveDiff } = useWorkflowDiffStore(
useShallow((state) => ({
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
}))
)
const hasBaseline = useWorkflowDiffStore((state) => Boolean(state.baselineWorkflow))
const baselineBlock = useWorkflowDiffStore((state) => state.baselineWorkflow?.blocks?.[blockId])
return useMemo((): CurrentWorkflow => {
const isSnapshotView = hasBaseline && hasActiveDiff && isDiffReady && !isShowingDiff
const block = isSnapshotView ? baselineBlock : normalBlock
const blocks: Record<string, BlockState> = block ? { [blockId]: block } : {}
return {
blocks,
edges: [],
loops: {},
parallels: {},
lastSaved: undefined,
isDiffMode: hasActiveDiff && isShowingDiff,
isNormalMode: !hasActiveDiff || (!isShowingDiff && !isSnapshotView),
isSnapshotView,
workflowState: { blocks, edges: [], loops: {}, parallels: {} },
getBlockById: (id: string) => (id === blockId ? block : undefined),
getBlockCount: () => (block ? 1 : 0),
getEdgeCount: () => 0,
hasBlocks: () => Boolean(block),
hasEdges: () => false,
}
}, [blockId, normalBlock, baselineBlock, hasBaseline, isShowingDiff, isDiffReady, hasActiveDiff])
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useBlockState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks'
import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useBlockCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-current-workflow'
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
import { useLastRunPath } from '@/stores/execution'
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
@@ -40,7 +40,7 @@ export function useBlockVisual({
const isEmbedded = data.isEmbedded ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const currentWorkflow = useCurrentWorkflow()
const currentWorkflow = useBlockCurrentWorkflow(blockId)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const {
@@ -1,9 +1,25 @@
import dynamic from 'next/dynamic'
import type { EdgeTypes, NodeTypes } from 'reactflow'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
/**
* Lazily loaded note node. Defined once at module scope so the {@link nodeTypes}
* map stays referentially stable for ReactFlow. Loading it dynamically keeps its
* heavy markdown dependencies (Streamdown, remark-breaks) off the editor's
* critical path — they load only when a workflow actually contains a note block.
* `ssr: false` is safe because the note node is canvas-only and never rendered
* on the server.
*/
const NoteBlock = dynamic(
() =>
import('@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block').then(
(mod) => mod.NoteBlock
),
{ ssr: false }
)
/** Custom node types for ReactFlow. */
export const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
@@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { usePresenceStore } from '@/stores/presence/store'
import { useSidebarStore } from '@/stores/sidebar/store'
/**
@@ -80,7 +81,8 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId }: AvatarsProps) {
const { presenceUsers, currentWorkflowId, currentSocketId } = useSocket()
const { currentWorkflowId, currentSocketId } = useSocket()
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
@@ -43,6 +43,8 @@ import type {
VariableUpdateEmit,
WorkflowOperationEmit,
} from '@/stores/operation-queue/types'
import { usePresenceStore } from '@/stores/presence/store'
import type { PresenceUser } from '@/stores/presence/types'
import { useWorkflowRegistry as useWorkflowRegistryStore } from '@/stores/workflows/registry/store'
const logger = createLogger('SocketContext')
@@ -71,15 +73,6 @@ interface User {
email?: string
}
interface PresenceUser {
socketId: string
userId: string
userName: string
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
interface SocketContextType {
socket: Socket | null
isConnected: boolean
@@ -95,7 +88,6 @@ interface SocketContextType {
blockedJoinWorkflowId: string | null
currentWorkflowId: string | null
currentSocketId: string | null
presenceUsers: PresenceUser[]
joinWorkflow: (workflowId: string) => void
leaveWorkflow: () => void
retryConnection: () => void
@@ -129,7 +121,6 @@ const SocketContext = createContext<SocketContextType>({
blockedJoinWorkflowId: null,
currentWorkflowId: null,
currentSocketId: null,
presenceUsers: [],
joinWorkflow: () => {},
leaveWorkflow: () => {},
retryConnection: () => {},
@@ -166,7 +157,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const [isRetryingWorkflowJoin, setIsRetryingWorkflowJoin] = useState(false)
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const [authFailed, setAuthFailed] = useState(false)
const [blockedJoinWorkflowId, setBlockedJoinWorkflowId] = useState<string | null>(null)
const [explicitWorkflowId, setExplicitWorkflowId] = useState<string | null>(null)
@@ -202,6 +192,21 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
/**
* Presence is high-frequency (cursor frames many times per second) so it lives
* in {@link usePresenceStore}, not the broad socket context — writing it here no
* longer mints a new context value, so emitter-only `useSocket()` consumers stop
* re-rendering on every cursor frame. These thin wrappers delegate to the store's
* stable actions read via `getState()`.
*/
const setPresenceUsers = useCallback((users: PresenceUser[]) => {
usePresenceStore.getState().setPresenceUsers(users)
}, [])
const updatePresenceUsers = useCallback((updater: (prev: PresenceUser[]) => PresenceUser[]) => {
usePresenceStore.getState().updatePresenceUsers(updater)
}, [])
const setVisibleWorkflowId = useCallback((workflowId: string | null) => {
currentWorkflowIdRef.current = workflowId
setCurrentWorkflowId(workflowId)
@@ -255,7 +260,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
setPresenceUsers([])
setVisibleWorkflowId(null)
},
[resetVisibleWorkflowState, setVisibleWorkflowId]
[resetVisibleWorkflowState, setPresenceUsers, setVisibleWorkflowId]
)
const executeJoinCommands = useCallback(
@@ -501,7 +506,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
return
}
setPresenceUsers((prev) => {
updatePresenceUsers((prev) => {
const prevMap = new Map(prev.map((u) => [u.socketId, u]))
return users.map((user) => {
@@ -675,7 +680,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
return
}
setPresenceUsers((prev) => {
updatePresenceUsers((prev) => {
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
if (existingIndex === -1) {
logger.debug('Received cursor-update for unknown user', { socketId: data.socketId })
@@ -693,7 +698,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
return
}
setPresenceUsers((prev) => {
updatePresenceUsers((prev) => {
const existingIndex = prev.findIndex((user) => user.socketId === data.socketId)
if (existingIndex === -1) {
logger.debug('Received selection-update for unknown user', {
@@ -779,6 +784,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketRef.current.close()
socketRef.current = null
}
// Clear the module-global presence store on unmount to match the prior per-provider lifetime.
usePresenceStore.getState().clearPresenceUsers()
}
}, [user?.id])
@@ -1116,7 +1124,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
blockedJoinWorkflowId,
currentWorkflowId,
currentSocketId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
retryConnection,
@@ -1147,7 +1154,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
blockedJoinWorkflowId,
currentWorkflowId,
currentSocketId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
retryConnection,
+110 -17
View File
@@ -12,18 +12,104 @@ import {
} from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronRight } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { cn } from '@/lib/core/utils/cn'
import './code.css'
/**
* Re-export Prism.js highlighting utilities for use across the app.
* Components can import these instead of importing from prismjs directly.
* Shape of the lazily-loaded Prism module (`./prism`), narrowed to the two
* members this component uses for highlighting.
*/
export { highlight, languages }
type PrismModule = typeof import('./prism')
/**
* Module-level singleton promise for the lazily-loaded Prism module.
*
* Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
* out of this module's static import graph so it never lands in bundles that only
* pull `Code` through the shared `@/components/emcn` barrel. It is loaded once per
* session on the first highlight and cached here for all subsequent viewers.
*/
let prismModulePromise: Promise<PrismModule> | null = null
/**
* The resolved Prism module, cached synchronously once the first load settles so
* later viewers can initialize from it without a null→loaded render cycle.
*/
let resolvedPrism: PrismModule | null = null
/**
* Loads the Prism module once and caches both the in-flight promise and the
* resolved module for reuse.
*
* @returns A promise resolving to the Prism highlighting utilities.
*/
function loadPrism(): Promise<PrismModule> {
if (!prismModulePromise) {
prismModulePromise = import('./prism').then((mod) => {
resolvedPrism = mod
return mod
})
}
return prismModulePromise
}
/**
* Subscribes a client component to the lazily-loaded Prism module.
*
* Seeds from {@link resolvedPrism} so viewers mounted after the first load
* highlight synchronously; otherwise returns `null` until Prism resolves (so
* callers render the plaintext fallback), then the loaded module.
*
* @returns The loaded Prism module, or `null` while loading.
*/
function usePrism(): PrismModule | null {
const [prism, setPrism] = useState<PrismModule | null>(resolvedPrism)
useEffect(() => {
if (prism) return
let active = true
loadPrism().then((mod) => {
if (active) setPrism(mod)
})
return () => {
active = false
}
}, [prism])
return prism
}
/**
* Escapes HTML special characters so raw code can be safely injected via
* `dangerouslySetInnerHTML` as the plaintext fallback before Prism loads (and
* for unknown languages). Matches Prism's own escaping for visual parity.
*
* @param text - The raw code text to escape
* @returns The HTML-escaped text
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Highlights a single line of code, falling back to escaped plaintext when Prism
* has not loaded yet or the language grammar is unavailable.
*
* @param prism - The loaded Prism module, or `null` while loading
* @param text - The line of code to highlight
* @param language - The language key (e.g. `json`, `javascript`, `python`)
* @returns Highlighted HTML, or escaped plaintext as a fallback
*/
function highlightOrEscape(prism: PrismModule | null, text: string, language: string): string {
if (!prism) return escapeHtml(text)
const grammar = prism.languages[language] || prism.languages.javascript
return prism.highlight(text, grammar, language)
}
/**
* Code editor configuration and constants.
@@ -844,6 +930,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
const prism = usePrism()
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
@@ -890,11 +977,10 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
// Only process visible lines for efficiency (not all lines)
const visibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
const hasSearch = searchQuery?.trim()
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
let html = highlightOrEscape(prism, displayLines[idx], language)
if (hasSearch && searchQuery) {
const result = applySearchHighlightingToLine(
@@ -908,7 +994,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
return { lineNumber: idx + 1, html }
})
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
}, [
prism,
displayLines,
language,
visibleLineIndices,
searchQuery,
currentMatchIndex,
matchOffsets,
])
const hasCollapsibleContent = collapsibleLines.size > 0
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
@@ -1037,6 +1131,7 @@ const ViewerInner = memo(function ViewerInner({
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const prism = usePrism()
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
@@ -1083,22 +1178,21 @@ const ViewerInner = memo(function ViewerInner({
// Pre-compute highlighted lines with search for visible indices (for gutter mode)
const highlightedVisibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
if (!searchQuery?.trim()) {
return visibleLineIndices.map((idx) => ({
lineNumber: idx + 1,
html: highlight(displayLines[idx], lang, language) || '&nbsp;',
html: highlightOrEscape(prism, displayLines[idx], language) || '&nbsp;',
}))
}
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
let html = highlightOrEscape(prism, displayLines[idx], language)
const matchCounter = { count: cumulativeMatches[idx] }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
return { lineNumber: idx + 1, html: html || '&nbsp;' }
})
}, [
prism,
displayLines,
language,
visibleLineIndices,
@@ -1109,16 +1203,15 @@ const ViewerInner = memo(function ViewerInner({
// Pre-compute simple highlighted code (for no-gutter mode)
const highlightedCode = useMemo(() => {
const lang = languages[language] || languages.javascript
const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
let html = highlight(visibleCode, lang, language)
let html = highlightOrEscape(prism, visibleCode, language)
if (searchQuery?.trim()) {
const matchCounter = { count: 0 }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
}
return html
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
}, [prism, displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
@@ -0,0 +1,19 @@
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
/**
* Prism.js highlighting utilities isolated in a dedicated module.
*
* The grammar imports above are side-effectful (they register languages on the
* shared `Prism.languages` registry), which marks any module that statically
* imports them as having side effects and therefore non-tree-shakeable. Keeping
* them here — rather than in `code.tsx` — ensures Prism only enters bundles that
* actually import `highlight`/`languages`, instead of every consumer of the
* shared `@/components/emcn` barrel (which re-exports `Code`).
*
* `code.tsx` itself never imports this module statically; it loads it lazily via
* dynamic `import()` on first highlight.
*/
export { highlight, languages }
+1 -2
View File
@@ -76,10 +76,9 @@ export {
Code,
calculateGutterWidth,
getCodeEditorProps,
highlight,
languages,
} from './code/code'
export { CopyCodeButton } from './code/copy-code-button'
export { highlight, languages } from './code/prism'
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
export {
Combobox,
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { PresenceState } from '@/stores/presence/types'
/**
* Live collaborator presence for the active workflow room.
*
* Presence is high-frequency (cursor frames arrive many times per second), so it
* lives in its own store rather than the broad socket context. Only presence
* consumers (`<Cursors>`, `<Avatars>`) subscribe to it, so cursor frames no
* longer re-render emitter-only `useSocket()` consumers such as `WorkflowContent`.
*/
export const usePresenceStore = create<PresenceState>()(
devtools(
(set) => ({
presenceUsers: [],
setPresenceUsers: (users) => set({ presenceUsers: users }),
updatePresenceUsers: (updater) =>
set((state) => ({ presenceUsers: updater(state.presenceUsers) })),
clearPresenceUsers: () => set({ presenceUsers: [] }),
}),
{ name: 'presence-store' }
)
)
+23
View File
@@ -0,0 +1,23 @@
/**
* A collaborator present in the active workflow room. Mirrors the presence
* payload broadcast by the realtime server (`presence-update`, cursor/selection
* deltas, and `join-workflow-success`).
*/
export interface PresenceUser {
socketId: string
userId: string
userName: string
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
export interface PresenceState {
presenceUsers: PresenceUser[]
/** Replace the full presence list (join success, presence-update). */
setPresenceUsers: (users: PresenceUser[]) => void
/** Apply a functional update to the presence list (cursor/selection deltas). */
updatePresenceUsers: (updater: (prev: PresenceUser[]) => PresenceUser[]) => void
/** Clear presence when leaving or losing the workflow room. */
clearPresenceUsers: () => void
}