From f930432ffeb2f4ed2f99e27d2fac2cb353111521 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Fri, 5 Jun 2026 11:32:18 +0800 Subject: [PATCH] fix(tree-select): hoist Row to module scope to prevent row remounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- components/tree-select/VirtualizedTree.tsx | 136 ++++++++++++--------- 1 file changed, 76 insertions(+), 60 deletions(-) diff --git a/components/tree-select/VirtualizedTree.tsx b/components/tree-select/VirtualizedTree.tsx index 898a982..7c5af10 100644 --- a/components/tree-select/VirtualizedTree.tsx +++ b/components/tree-select/VirtualizedTree.tsx @@ -4,8 +4,8 @@ import { TreeNodeData, UseTreeReturnType, } from "@mantine/core" -import React, { CSSProperties, useMemo } from "react" -import { List } from "react-window" +import React, { useMemo } from "react" +import { List, RowComponentProps } from "react-window" import "./mantine-scrollbar.css" export interface VirtualizedTreeProps { @@ -51,6 +51,78 @@ function flattenByExpanded( 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) { + 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
{renderNode(payload)}
+} + /** * VirtualizedTree - Mantine v8 compatible virtualized Tree * - Accepts tree (useTree() result) and data: TreeNodeData[] @@ -72,62 +144,6 @@ export function VirtualizedTree({ flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0), [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
{renderNode(payload)}
- } return ( } - rowProps={{}} + rowComponent={Row} + rowProps={{ flattened, tree, renderNode }} overscanCount={overscanCount}> ) }