From c87e16037499b7856d0bb6fee8ffaf76b3e50283 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:37:19 +0800 Subject: [PATCH] perf(issues): stop double full-tree render passes on surface interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trace forensics on view switching showed every interaction paying TWO full surface render passes (React's own Cascading Update marker sits between them): entering swimlane flips controller-level loading state (loadProjects enables the projects query), and any such flag flip re-rendered the entire unmemoized view tree (~600-1000ms dev per pass). - Memoize BoardView / ListView / SwimLaneView: controller/data outputs are already useMemo/useCallback-stable, so a controller flag flip now re-renders the header, not the whole board. The one unstable prop — BoardView's inline assigneeGroups.flatMap — moves into a useMemo. - Selection reset on mount swapped the initial empty Set for a NEW empty Set, buying a guaranteed extra full pass per surface mount; functional bail keeps the reference when nothing was selected. Co-Authored-By: Claude Fable 5 --- packages/views/issues/components/board-view.tsx | 10 +++++++++- packages/views/issues/components/list-view.tsx | 12 ++++++++++-- .../views/issues/components/swimlane-view.tsx | 12 ++++++++++-- packages/views/issues/surface/issue-surface.tsx | 15 ++++++++++----- .../views/issues/surface/selection-context.tsx | 5 ++++- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index 0de0278107..a527e2aef1 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -116,7 +116,7 @@ function buildGroups( const EMPTY_PROGRESS_MAP = new Map(); const EMPTY_IDS: string[] = []; -export function BoardView({ +function BoardViewImpl({ issues, assigneeGroups, assigneeGroupQueryKey, @@ -643,3 +643,11 @@ function BoardHiddenColumnsPanel({ /> ); } + +/** + * Memoized: the surface controller re-renders on loading-flag flips (e.g. a + * query enabling when the view changes) — without memo every such flip + * re-rendered this entire view tree (hundreds of ms). All props are + * referentially stable useMemo/useCallback outputs from the controller. + */ +export const BoardView = memo(BoardViewImpl); diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 80e6d9a00a..07c6966fc5 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react"; import { ChevronRight, Plus } from "lucide-react"; import { Accordion } from "@base-ui/react/accordion"; import { @@ -68,7 +68,7 @@ function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] { })); } -export function ListView({ +function ListViewImpl({ issues, visibleStatuses, childProgressMap = EMPTY_PROGRESS_MAP, @@ -594,3 +594,11 @@ function StatusAccordionItem({ ); } + +/** + * Memoized: the surface controller re-renders on loading-flag flips (e.g. a + * query enabling when the view changes) — without memo every such flip + * re-rendered this entire view tree (hundreds of ms). All props are + * referentially stable useMemo/useCallback outputs from the controller. + */ +export const ListView = memo(ListViewImpl); diff --git a/packages/views/issues/components/swimlane-view.tsx b/packages/views/issues/components/swimlane-view.tsx index 9b5c3e5d3e..bd579caf94 100644 --- a/packages/views/issues/components/swimlane-view.tsx +++ b/packages/views/issues/components/swimlane-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react"; import { DndContext, DragOverlay, @@ -450,7 +450,7 @@ function buildAssigneeLanes( ]; } -export function SwimLaneView({ +function SwimLaneViewImpl({ issues, unfilteredIssues, activeFilters: activeFiltersProp, @@ -1641,3 +1641,11 @@ function SwimLaneLoadMoreCell({ if (!hasMore) return
; return ; } + +/** + * Memoized: the surface controller re-renders on loading-flag flips (e.g. a + * query enabling when the view changes) — without memo every such flip + * re-rendered this entire view tree (hundreds of ms). All props are + * referentially stable useMemo/useCallback outputs from the controller. + */ +export const SwimLaneView = memo(SwimLaneViewImpl); diff --git a/packages/views/issues/surface/issue-surface.tsx b/packages/views/issues/surface/issue-surface.tsx index 2d72cac3b6..c8d5a01b50 100644 --- a/packages/views/issues/surface/issue-surface.tsx +++ b/packages/views/issues/surface/issue-surface.tsx @@ -146,6 +146,15 @@ function IssueSurfaceContent({ }, [controller], ); + // Stable reference for BoardView's issues: the inline flatMap allocated a + // fresh array every render, defeating BoardView's memo. + const boardIssues = useMemo( + () => + controller.assigneeGroups + ? controller.assigneeGroups.flatMap((group) => group.issues) + : issues, + [controller.assigneeGroups, issues], + ); const shouldShowClientEmpty = !!clientFilter && issues.length === 0 && @@ -200,11 +209,7 @@ function IssueSurfaceContent({
{controller.viewMode === "board" && ( group.issues) - : issues - } + issues={boardIssues} assigneeGroups={controller.assigneeGroups} assigneeGroupQueryKey={controller.assigneeGroupQueryKey} assigneeGroupFilter={controller.assigneeGroupFilter} diff --git a/packages/views/issues/surface/selection-context.tsx b/packages/views/issues/surface/selection-context.tsx index 69f5f682a1..2090bf44f5 100644 --- a/packages/views/issues/surface/selection-context.tsx +++ b/packages/views/issues/surface/selection-context.tsx @@ -29,7 +29,10 @@ export function useCreateIssueSurfaceSelection( const [selectedIds, setSelectedIds] = useState(() => new Set()); useEffect(() => { - setSelectedIds(new Set()); + // Functional bail: on mount (and on resetKey changes where nothing was + // selected) the selection is already empty — swapping in a NEW empty Set + // here re-rendered the entire surface once per mount for nothing. + setSelectedIds((current) => (current.size === 0 ? current : new Set())); }, [resetKey]); const toggle = useCallback((id: string) => {