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>
This commit is contained in:
@@ -4,8 +4,8 @@ import {
|
|||||||
TreeNodeData,
|
TreeNodeData,
|
||||||
UseTreeReturnType,
|
UseTreeReturnType,
|
||||||
} from "@mantine/core"
|
} from "@mantine/core"
|
||||||
import React, { CSSProperties, useMemo } from "react"
|
import React, { useMemo } from "react"
|
||||||
import { List } from "react-window"
|
import { List, RowComponentProps } from "react-window"
|
||||||
import "./mantine-scrollbar.css"
|
import "./mantine-scrollbar.css"
|
||||||
|
|
||||||
export interface VirtualizedTreeProps {
|
export interface VirtualizedTreeProps {
|
||||||
@@ -51,6 +51,78 @@ function flattenByExpanded(
|
|||||||
return res
|
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
|
* VirtualizedTree - Mantine v8 compatible virtualized Tree
|
||||||
* - Accepts tree (useTree() result) and data: TreeNodeData[]
|
* - Accepts tree (useTree() result) and data: TreeNodeData[]
|
||||||
@@ -72,62 +144,6 @@ export function VirtualizedTree({
|
|||||||
flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0),
|
flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0),
|
||||||
[data, tree]
|
[data, tree]
|
||||||
)
|
)
|
||||||
// react-window 的行渲染
|
|
||||||
/** 单行渲染组件 */
|
|
||||||
const Row = ({ index, style }: { index: number; style: CSSProperties }) => {
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
@@ -135,8 +151,8 @@ export function VirtualizedTree({
|
|||||||
style={{ height, width: "100%" }}
|
style={{ height, width: "100%" }}
|
||||||
className="custom-scrollbar"
|
className="custom-scrollbar"
|
||||||
rowHeight={itemSize}
|
rowHeight={itemSize}
|
||||||
rowComponent={({ index, style }) => <Row index={index} style={style} />}
|
rowComponent={Row}
|
||||||
rowProps={{}}
|
rowProps={{ flattened, tree, renderNode }}
|
||||||
overscanCount={overscanCount}></List>
|
overscanCount={overscanCount}></List>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user