From 3fa22d822a36c9c8320f24f98375900bb0d7a33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Mon, 28 Sep 2026 10:36:12 +0800 Subject: [PATCH] fix: preserve zero-valued checkable content --- package.json | 2 +- src/OptionList.tsx | 11 ++++++----- src/TreeSelect.tsx | 34 ++++++++++++++++++++-------------- src/hooks/useDataEntities.ts | 5 ++--- src/utils/valueUtil.ts | 2 -- src/utils/warningPropsUtil.ts | 4 ++-- tests/renderability.test.tsx | 24 ++++++++++++++++++++++++ 7 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 tests/renderability.test.tsx diff --git a/package.json b/package.json index 3a6ed9b7..a108d667 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@rc-component/select": "~1.11.0", "@rc-component/tree": "~1.5.3", - "@rc-component/util": "^1.11.1", + "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "devDependencies": { diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 75ae9bf3..a512798a 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -3,7 +3,7 @@ import type { TreeProps } from '@rc-component/tree'; import Tree from '@rc-component/tree'; import { UnstableContext } from '@rc-component/tree'; import type { EventDataNode } from '@rc-component/tree'; -import { KeyCode, useEvent, useMemo } from '@rc-component/util'; +import { isReactRenderable, KeyCode, useEvent, useMemo } from '@rc-component/util'; import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; @@ -90,7 +90,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { - if (!checkable) { + if (!isReactRenderable(checkable)) { return null; } @@ -117,7 +117,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, const onInternalSelect = (__: Key[], info: TreeEventInfo) => { const { node } = info; - if (checkable && isCheckDisabled(node)) { + if (isReactRenderable(checkable) && isCheckDisabled(node)) { return; } @@ -261,7 +261,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, }; // single mode active first checked node - const nextActiveKey = !multiple && checkedKeys.length && !searchValue ? checkedKeys[0] : getFirstNode(); + const nextActiveKey = + !multiple && checkedKeys.length && !searchValue ? checkedKeys[0] : getFirstNode(); setActiveKey(nextActiveKey); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -365,7 +366,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, checkable={checkable} checkStrictly checkedKeys={mergedCheckedKeys} - selectedKeys={!checkable ? checkedKeys : []} + selectedKeys={!isReactRenderable(checkable) ? checkedKeys : []} defaultExpandAll={treeDefaultExpandAll} titleRender={treeTitleRender} {...treeProps} diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 75d88f8c..faed7f9b 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -1,7 +1,7 @@ import type { BaseSelectPropsWithoutPrivate, BaseSelectRef } from '@rc-component/select'; import { BaseSelect } from '@rc-component/select'; import { conductCheck } from '@rc-component/tree'; -import { useControlledState, useId } from '@rc-component/util'; +import { isNonNullable, isReactRenderable, useControlledState, useId } from '@rc-component/util'; import * as React from 'react'; import useCache from './hooks/useCache'; import useCheckedKeys from './hooks/useCheckedKeys'; @@ -17,7 +17,7 @@ import TreeSelectContext from './TreeSelectContext'; import { fillAdditionalInfo, fillLegacyProps } from './utils/legacyUtil'; import type { CheckedStrategy } from './utils/strategyUtil'; import { formatStrategyValues, SHOW_ALL, SHOW_CHILD, SHOW_PARENT } from './utils/strategyUtil'; -import { fillFieldNames, isNil, toArray } from './utils/valueUtil'; +import { fillFieldNames, toArray } from './utils/valueUtil'; import warningProps from './utils/warningPropsUtil'; import type { LabeledValueType, @@ -203,10 +203,11 @@ const TreeSelect = React.forwardRef((props, ref) } = props; const mergedId = useId(id); - const treeConduction = treeCheckable && !treeCheckStrictly; - const mergedCheckable = treeCheckable || treeCheckStrictly; + const hasTreeCheckable = isReactRenderable(treeCheckable); + const treeConduction = hasTreeCheckable && !treeCheckStrictly; + const mergedCheckable = hasTreeCheckable ? treeCheckable : treeCheckStrictly; const mergedLabelInValue = treeCheckStrictly || labelInValue; - const mergedMultiple = mergedCheckable || multiple; + const mergedMultiple = isReactRenderable(mergedCheckable) || multiple; const searchProps = { searchValue: legacySearchValue, @@ -227,14 +228,14 @@ const TreeSelect = React.forwardRef((props, ref) const [internalValue, setInternalValue] = useControlledState(defaultValue, value); - // `multiple` && `!treeCheckable` should be show all + // `multiple` && `!hasTreeCheckable` should be show all const mergedShowCheckedStrategy = React.useMemo(() => { - if (!treeCheckable) { + if (!hasTreeCheckable) { return SHOW_ALL; } return showCheckedStrategy || SHOW_CHILD; - }, [showCheckedStrategy, treeCheckable]); + }, [showCheckedStrategy, hasTreeCheckable]); // ========================== Warning =========================== if (process.env.NODE_ENV !== 'production') { @@ -426,7 +427,12 @@ const TreeSelect = React.forwardRef((props, ref) const firstVal = rawDisplayValues[0]; - if (!mergedMultiple && firstVal && isNil(firstVal.value) && isNil(firstVal.label)) { + if ( + !mergedMultiple && + firstVal && + !isNonNullable(firstVal.value) && + !isNonNullable(firstVal.label) + ) { return []; } @@ -451,12 +457,12 @@ const TreeSelect = React.forwardRef((props, ref) const mergedMaxCount = React.useMemo(() => { if ( mergedMultiple && - (mergedShowCheckedStrategy === 'SHOW_CHILD' || treeCheckStrictly || !treeCheckable) + (mergedShowCheckedStrategy === 'SHOW_CHILD' || treeCheckStrictly || !hasTreeCheckable) ) { return maxCount; } return null; - }, [maxCount, mergedMultiple, treeCheckStrictly, mergedShowCheckedStrategy, treeCheckable]); + }, [maxCount, mergedMultiple, treeCheckStrictly, mergedShowCheckedStrategy, hasTreeCheckable]); // =========================== Change =========================== const triggerChange = useRefFunc( @@ -533,7 +539,7 @@ const TreeSelect = React.forwardRef((props, ref) mergedFieldNames, ); - if (mergedCheckable) { + if (isReactRenderable(mergedCheckable)) { additionalInfo.checked = selected; } else { additionalInfo.selected = selected; @@ -661,7 +667,7 @@ const TreeSelect = React.forwardRef((props, ref) onPopupScroll, leftMaxCount: maxCount === undefined ? null : maxCount - cachedDisplayValues.length, leafCountOnly: - mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && !!treeCheckable, + mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && hasTreeCheckable, valueEntities, classNames: treeSelectClassNames, styles, @@ -682,7 +688,7 @@ const TreeSelect = React.forwardRef((props, ref) cachedDisplayValues.length, mergedShowCheckedStrategy, treeCheckStrictly, - treeCheckable, + hasTreeCheckable, valueEntities, treeSelectClassNames, styles, diff --git a/src/hooks/useDataEntities.ts b/src/hooks/useDataEntities.ts index c618ce7b..5048d8d9 100644 --- a/src/hooks/useDataEntities.ts +++ b/src/hooks/useDataEntities.ts @@ -1,8 +1,7 @@ import * as React from 'react'; import { convertDataToEntities } from '@rc-component/tree'; import type { SafeKey, FieldNames } from '../interface'; -import { warning } from '@rc-component/util'; -import { isNil } from '../utils/valueUtil'; +import { isNonNullable, warning } from '@rc-component/util'; export type DataEntity = ReturnType['keyEntities'][string]; @@ -24,7 +23,7 @@ export default (treeData: any, fieldNames: FieldNames) => if (process.env.NODE_ENV !== 'production') { const key = entity.node.key; - warning(!isNil(val), 'TreeNode `value` is invalidate: undefined'); + warning(isNonNullable(val), 'TreeNode `value` is invalidate: undefined'); warning(!wrapper.valueEntities.has(val), `Same \`value\` exist in the tree: ${val}`); warning( !key || String(key) === String(val), diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index caa44727..0bc06798 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -33,5 +33,3 @@ export const getAllKeys = (treeData: DataNode[], fieldNames: FieldNames): SafeKe return keys; }; - -export const isNil = (val: any): boolean => val === null || val === undefined; diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index 3aa10ba5..ddd012da 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -1,4 +1,4 @@ -import { warning } from '@rc-component/util'; +import { isReactRenderable, warning } from '@rc-component/util'; import type { TreeSelectProps } from '../TreeSelect'; import { toArray } from './valueUtil'; @@ -27,7 +27,7 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { ); } - if (treeCheckStrictly || multiple || treeCheckable) { + if (treeCheckStrictly || multiple || isReactRenderable(treeCheckable)) { warning( !value || Array.isArray(value), '`value` should be an array when `TreeSelect` is checkable or multiple.', diff --git a/tests/renderability.test.tsx b/tests/renderability.test.tsx new file mode 100644 index 00000000..2d95c0c5 --- /dev/null +++ b/tests/renderability.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TreeSelect from '../src'; + +describe('ReactNode renderability', () => { + const treeData = [{ value: 'a', title: 'A', children: [{ value: 'b', title: 'B' }] }]; + + it('enables multiple mode and the checked strategy for a zero checkbox', () => { + const { container } = render( + , + ); + expect(container.querySelector('.rc-tree-select-multiple')).toBeTruthy(); + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(1); + expect(container.querySelector('.rc-tree-select-selection-item').textContent).toContain('B'); + }); + + it('retains a zero label for a null value', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('0'); + expect(container.textContent).not.toContain('EMPTY'); + }); +});