perf(issues): stop double full-tree render passes on surface interactions

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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-15 15:37:19 +08:00
parent 52e2258049
commit c87e160374
5 changed files with 43 additions and 11 deletions

View File

@@ -116,7 +116,7 @@ function buildGroups(
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
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);

View File

@@ -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({
</Accordion.Item>
);
}
/**
* 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);

View File

@@ -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 <div />;
return <InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />;
}
/**
* 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);

View File

@@ -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({
<div className={cn("flex flex-col flex-1 min-h-0", contentClassName)}>
{controller.viewMode === "board" && (
<BoardView
issues={
controller.assigneeGroups
? controller.assigneeGroups.flatMap((group) => group.issues)
: issues
}
issues={boardIssues}
assigneeGroups={controller.assigneeGroups}
assigneeGroupQueryKey={controller.assigneeGroupQueryKey}
assigneeGroupFilter={controller.assigneeGroupFilter}

View File

@@ -29,7 +29,10 @@ export function useCreateIssueSurfaceSelection(
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
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) => {