mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user