Files
labelmain-demo/components/tree-select/VirtualizedTree.tsx
zhangheng f930432ffe fix(tree-select): hoist Row to module scope to prevent row remounts
The row renderer was defined inside VirtualizedTree, giving it a new
component identity on every parent render — react-window then remounted
every row, destroying row DOM/state. Move Row to module scope and pass
flattened/tree/renderNode through react-window's rowProps instead of the
closure.

Verified at runtime: rows render, expand/collapse and select work, and
selection state survives re-renders, with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:32:18 +08:00

159 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// VirtualizedTree.tsx
import {
RenderTreeNodePayload,
TreeNodeData,
UseTreeReturnType,
} from "@mantine/core"
import React, { useMemo } from "react"
import { List, RowComponentProps } from "react-window"
import "./mantine-scrollbar.css"
export interface VirtualizedTreeProps {
/** Mantine tree controller (from useTree()) — optional, if not provided this component won't control state */
tree: UseTreeReturnType
/** Mantine tree data */
data: TreeNodeData[]
/** renderNode keeps same signature as Mantine: receives RenderTreeNodePayload */
renderNode: (payload: RenderTreeNodePayload) => React.ReactNode
/** pixels to indent per level (matches Mantine's levelOffset) */
levelOffset?: number
/** react-window container height */
height?: string | number
/** react-window item (row) height */
itemSize?: number
/** overscan rows */
overscanCount?: number
}
/** 小工具:把 TreeNodeData 扁平化为 [node, level] 列表,依据 tree.expandedState */
function flattenByExpanded(
nodes: TreeNodeData[],
expandedState: Record<string, boolean>,
level = 0
): { node: TreeNodeData; level: number }[] {
const res: { node: TreeNodeData; level: number }[] = []
for (const node of nodes) {
res.push({ node, level })
const value = node.value
const isExpanded = !!expandedState?.[String(value)]
if (isExpanded && node.children && node.children.length > 0) {
res.push(...flattenByExpanded(node.children, expandedState, level + 1))
}
}
return res
}
/** 行渲染所需的闭包数据,通过 react-window 的 rowProps 传入 */
interface RowData {
flattened: { node: TreeNodeData; level: number }[]
tree: UseTreeReturnType
renderNode: (payload: RenderTreeNodePayload) => React.ReactNode
}
/**
* 单行渲染组件 —— 提到模块作用域,避免每次父组件渲染都生成新的组件实例
* (否则 react-window 会在每次渲染时 remount 所有行,销毁行内状态)
*/
function Row({
index,
style,
flattened,
tree,
renderNode,
}: RowComponentProps<RowData>) {
const { node, level } = flattened[index]
// 构造 Mantine 的 RenderTreeNodePayload
const nodeValue = String(node.value)
const hasChildren = Array.isArray(node.children) && node.children.length > 0
const expanded = !!(tree && (tree as any).expandedState?.[nodeValue])
const selected = !!(
tree && (tree as any).selectedState?.includes?.(nodeValue)
)
const hovered = tree ? (tree as any).hoveredNode === nodeValue : false
// elementProps: 与 Mantine Tree 内部的 props shape 对齐,外部的 renderNode 会 spread 这个对象
const elementProps = {
className: "",
style: {
display: "flex",
alignItems: "center",
width: "100%",
} as React.CSSProperties,
onClick: (e: React.MouseEvent) => {
// 保持与 Mantine Tree 一致的基础行为:选中、展开切换由 tree 控制
// 点击行为在业务端通常由 renderNode 决定(例如中间区域用于 toggleExpanded
// 这里我们调用 tree.toggleExpanded 以保证基础交互一致(不会阻止外部覆盖)
if (tree && typeof tree.toggleExpanded === "function") {
tree.toggleExpanded(nodeValue)
}
false && console.log(e)
},
"data-selected": selected as boolean | undefined,
"data-value": nodeValue,
"data-hovered": hovered as boolean | undefined,
}
const payload: RenderTreeNodePayload = {
level,
expanded,
hasChildren,
selected,
node,
tree,
elementProps,
}
// paddingLeft 模拟 Mantine 的 levelOffset 行为renderNode 可使用 elementProps.style 覆盖)
const paddedStyle: React.CSSProperties = {
...style,
boxSizing: "border-box",
overflow: "hidden",
}
// 将 style 作为容器传入Mantine 的 renderNode 也会接受 elementProps.style
return <div style={paddedStyle}>{renderNode(payload)}</div>
}
/**
* VirtualizedTree - Mantine v8 compatible virtualized Tree
* - Accepts tree (useTree() result) and data: TreeNodeData[]
* - Passes RenderTreeNodePayload to renderNode exactly like Mantine
* - Uses react-window FixedSizeList for virtualization
*/
export function VirtualizedTree({
tree,
data,
renderNode,
// levelOffset = 24,
height = 400,
itemSize = 36,
overscanCount = 5,
}: VirtualizedTreeProps) {
// 扁平化数据:根据 tree.expandedState 决定哪些子节点可见
const flattened = useMemo(
() =>
flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0),
[data, tree]
)
return (
<List
rowCount={flattened.length}
style={{ height, width: "100%" }}
className="custom-scrollbar"
rowHeight={itemSize}
rowComponent={Row}
rowProps={{ flattened, tree, renderNode }}
overscanCount={overscanCount}></List>
)
}