From cd2e482139371ef8391ba6341d6accb95ee27102 Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:13:52 +0800 Subject: [PATCH 1/3] fix(TreeSelect): preserve zero-valued branch selection --- .../tree-select/__test__/zero-values.test.js | 101 ++++++++++++++++++ .../components/tree-select/tree-select.ts | 2 +- .../tree-select/tree-select.vue | 2 +- 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 packages/components/tree-select/__test__/zero-values.test.js diff --git a/packages/components/tree-select/__test__/zero-values.test.js b/packages/components/tree-select/__test__/zero-values.test.js new file mode 100644 index 0000000000..169986291c --- /dev/null +++ b/packages/components/tree-select/__test__/zero-values.test.js @@ -0,0 +1,101 @@ +import path from 'path'; +import simulate from 'miniprogram-simulate'; + +describe('TreeSelect falsy node values', () => { + const treeSelect = load(path.resolve(__dirname, '../tree-select')); + let component; + + const options = [ + { label: 'Other parent', value: 1, children: [{ label: 'Other leaf', value: 11 }] }, + { label: 'Zero parent', value: 0, children: [{ label: 'Zero leaf', value: 5 }] }, + ]; + + function mount(data = {}) { + const id = simulate.load({ + template: ``, + usingComponents: { 't-tree-select': treeSelect }, + data: { options, value: [0, 5], ...data }, + methods: { + onChange(event) { + this.setData({ value: event.detail.value }); + }, + }, + }); + component = simulate.render(id); + component.attach(document.createElement('parent-wrapper')); + return component.querySelector('#tree'); + } + + afterEach(() => { + component?.detach(); + }); + + it.each([0, ''])('renders the matching children for value %p', (value) => { + const tree = mount({ + options: [options[0], { ...options[1], value }], + value: [value, 5], + }); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]); + expect(tree.data.innerValue).toEqual([value, 5]); + expect(component.dom.textContent).toContain('Zero leaf'); + expect(component.dom.textContent).not.toContain('Other leaf'); + }); + + it('keeps a zero customValue ahead of a nonzero value', () => { + const tree = mount({ value: [1, 11], customValue: [0, 5] }); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]); + expect(tree.data.innerValue).toEqual([0, 5]); + expect(component.dom.textContent).toContain('Zero leaf'); + }); + + it('follows a zero value in the middle column', () => { + const tree = mount({ options: [{ label: 'Root', value: 7, children: options }], value: [7, 0, 5] }); + expect(tree.data.treeOptions[2].map((item) => item.value)).toEqual([5]); + expect(tree.data.innerValue).toEqual([7, 0, 5]); + expect(component.dom.textContent).toContain('Zero leaf'); + }); + + it('supports zero values with custom keys', () => { + const tree = mount({ + keys: { value: 'id', label: 'name', children: 'items' }, + options: [ + { name: 'Other parent', id: 1, items: [{ name: 'Other leaf', id: 11 }] }, + { name: 'Zero parent', id: 0, items: [{ name: 'Zero leaf', id: 5 }] }, + ], + }); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]); + expect(component.dom.textContent).toContain('Zero leaf'); + }); + + it('preserves the selected leaf array in multiple mode', () => { + const tree = mount({ value: [0, [5]], multiple: true }); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]); + expect(tree.data.innerValue).toEqual([0, [5]]); + expect(component.dom.textContent).toContain('Zero leaf'); + }); + + it('updates the rendered children after clicking the zero parent', async () => { + const tree = mount({ value: [1, 11] }); + expect(component.dom.textContent).toContain('Other leaf'); + const zeroParent = component.querySelector('#tree >>> #scroll-to-0'); + expect(zeroParent).toBeTruthy(); + zeroParent.dispatchEvent('tap'); + await simulate.sleep(10); + expect(tree.data.innerValue[0]).toBe(0); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]); + expect(component.dom.textContent).toContain('Zero leaf'); + expect(component.dom.textContent).not.toContain('Other leaf'); + }); + + it.each([[], null, undefined, [99]])('retains the first branch fallback for %p', (value) => { + const tree = mount({ value }); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([11]); + expect(component.dom.textContent).toContain('Other leaf'); + }); + + it('keeps customValue=[] unselected instead of applying default values', () => { + const tree = mount({ value: [], customValue: [] }); + expect(tree.data.innerValue).toEqual([]); + expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([11]); + }); +}); diff --git a/packages/components/tree-select/tree-select.ts b/packages/components/tree-select/tree-select.ts index 01b5ac66fd..e26837a857 100644 --- a/packages/components/tree-select/tree-select.ts +++ b/packages/components/tree-select/tree-select.ts @@ -75,7 +75,7 @@ export default class TreeSelect extends SuperComponent { treeOptions.push(currentLevelOptions); const currentValue = customValue?.[level] ?? value?.[level]; - currentNode = currentValue + currentNode = isDef(currentValue) ? (currentLevelOptions.find((child) => child.value === currentValue) ?? currentLevelOptions[0]) : currentLevelOptions[0]; } diff --git a/packages/uniapp-components/tree-select/tree-select.vue b/packages/uniapp-components/tree-select/tree-select.vue index 74bcd30898..db2dda5965 100644 --- a/packages/uniapp-components/tree-select/tree-select.vue +++ b/packages/uniapp-components/tree-select/tree-select.vue @@ -224,7 +224,7 @@ export default { treeOptions.push(currentLevelOptions); const currentValue = coalesce(customValue?.[level], value?.[level]); - currentNode = currentValue + currentNode = isDef(currentValue) ? coalesce( currentLevelOptions.find((child) => child.value === currentValue), currentLevelOptions[0], From 3479dc6ea03ebb2b7d9c91ed0a9eaa81dafb30ae Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:58:56 +0800 Subject: [PATCH 2/3] fix(TreeSelect): sync external values in uniapp --- .../tree-select/__test__/value.test.js | 189 ++++++++++++++++++ .../tree-select/tree-select.vue | 12 +- 2 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 packages/uniapp-components/tree-select/__test__/value.test.js diff --git a/packages/uniapp-components/tree-select/__test__/value.test.js b/packages/uniapp-components/tree-select/__test__/value.test.js new file mode 100644 index 0000000000..bdb7ab0bec --- /dev/null +++ b/packages/uniapp-components/tree-select/__test__/value.test.js @@ -0,0 +1,189 @@ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { transformSync } from '@babel/core'; +import { parse } from '@vue/compiler-sfc'; +import { createApp, h, nextTick, reactive } from 'vue'; + +global.uni = { + getWindowInfo: () => ({}), + getAppBaseInfo: () => ({}), + getDeviceInfo: () => ({}), + getSystemInfoSync: () => ({}), +}; + +// Jest has no Vue transformer. Run the actual SFC script and Vue watchers while +// stubbing presentation-only imports; the component's helpers remain unchanged. +const filename = path.resolve(__dirname, '../tree-select.vue'); +const { descriptor } = parse(fs.readFileSync(filename, 'utf8')); +const { code } = transformSync(descriptor.script.content, { + filename, + configFile: false, + babelrc: false, + plugins: ['@babel/plugin-transform-modules-commonjs'], +}); +const componentModule = { exports: {} }; +vm.runInThisContext(`(function(require, module, exports) { ${code}\n })`, { filename })( + (id) => { + if (/^\.\.\/(checkbox|radio|scroll-view|side-bar)/.test(id) || id.endsWith('.wxs')) return {}; + // eslint-disable-next-line import/no-dynamic-require, global-require + return require(path.resolve(path.dirname(filename), id)); + }, + componentModule, + componentModule.exports, +); +const TreeSelect = { ...componentModule.exports.default, render: () => null }; + +const options = [ + { label: 'First', value: 1, children: [{ value: 11 }, { value: 12 }] }, + { label: 'Second', value: 2, children: [{ value: 22 }, { value: 23 }] }, +]; + +describe('uniapp TreeSelect external values', () => { + let app; + let tree; + let state; + let container; + let change; + let update; + + async function mount(props, model = false) { + state = reactive({ options, ...props }); + change = jest.fn(); + update = jest.fn((value) => { + if (model) state.value = value; + }); + app = createApp({ + render: () => + h(TreeSelect, { + ...state, + ref: (instance) => { + tree = instance; + }, + onChange: change, + 'onUpdate:value': update, + }), + }); + container = document.createElement('div'); + document.body.appendChild(container); + app.mount(container); + await nextTick(); + } + + afterEach(() => { + app?.unmount(); + container?.remove(); + }); + + function selection() { + return { value: tree.innerValue, leaves: tree.treeOptions[1].map((item) => item.value) }; + } + + it.each(['value', 'customValue'])('responds to replaced %s without emitting a change', async (key) => { + await mount({ [key]: [1, 11] }); + state[key] = [2, 22]; + await nextTick(); + expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] }); + expect(change).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['value', 'customValue'])('responds to nested mutations of %s', async (key) => { + await mount({ [key]: [1, 11] }); + state[key].splice(0, 2, 2, 23); + await nextTick(); + expect(selection()).toEqual({ value: [2, 23], leaves: [22, 23] }); + }); + + it('resets value to the default first selection', async () => { + await mount({ value: [2, 22] }); + state.value = []; + await nextTick(); + expect(selection()).toEqual({ value: [1, 11], leaves: [11, 12] }); + }); + + it('keeps an empty customValue unselected', async () => { + await mount({ value: [1, 11], customValue: [2, 22] }); + state.customValue = []; + await nextTick(); + expect(selection()).toEqual({ value: [], leaves: [11, 12] }); + }); + + it('keeps customValue priority and restores the latest value when removed', async () => { + await mount({ value: [1, 11], customValue: [2, 22] }); + state.value = [1, 12]; + await nextTick(); + expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] }); + state.customValue = undefined; + await nextTick(); + expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] }); + expect(change).not.toHaveBeenCalled(); + }); + + it('restores defaultValue when customValue is removed without a value', async () => { + await mount({ defaultValue: [1, 12], customValue: [2, 22] }); + state.customValue = undefined; + await nextTick(); + expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] }); + }); + + it('preserves uncontrolled selection after an interaction and options update', async () => { + await mount({ defaultValue: [1, 11] }); + tree.handleChange({ value: 12, level: 1, type: 'single' }); + await nextTick(); + state.options = [...options]; + await nextTick(); + expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] }); + expect(state.value).toBeUndefined(); + expect(change).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it('does not turn defaultValue into a controlled prop', async () => { + await mount({ defaultValue: [1, 11] }); + state.defaultValue = [2, 22]; + await nextTick(); + expect(selection()).toEqual({ value: [1, 11], leaves: [11, 12] }); + }); + + it('restores the uncontrolled selection after a temporary customValue override', async () => { + await mount({ defaultValue: [1, 11] }); + tree.handleChange({ value: 12, level: 1, type: 'single' }); + await nextTick(); + state.customValue = [2, 22]; + await nextTick(); + expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] }); + state.defaultValue = [2, 23]; + state.customValue = undefined; + await nextTick(); + expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] }); + expect(change).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it('does not emit again when a v-model update reaches the value watcher', async () => { + await mount({ value: [1, 11] }, true); + tree.onRootChange({ value: 2 }); + await nextTick(); + expect(tree.treeOptions[1].map((item) => item.value)).toEqual([22, 23]); + expect(state.value[0]).toBe(2); + expect(change).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it.each(['value', 'customValue'])('preserves multiple selection when %s changes', async (key) => { + await mount({ multiple: true, [key]: [1, [11]] }); + state[key] = [2, [22, 23]]; + await nextTick(); + expect(selection()).toEqual({ value: [2, [22, 23]], leaves: [22, 23] }); + }); + + it('uses the latest value when asynchronous options arrive', async () => { + await mount({ options: [], value: [1, 11] }); + state.value = [2, 22]; + await nextTick(); + state.options = options; + await nextTick(); + expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] }); + }); +}); diff --git a/packages/uniapp-components/tree-select/tree-select.vue b/packages/uniapp-components/tree-select/tree-select.vue index db2dda5965..d2417464f9 100644 --- a/packages/uniapp-components/tree-select/tree-select.vue +++ b/packages/uniapp-components/tree-select/tree-select.vue @@ -173,12 +173,14 @@ export default { }, watch: { value: { - handler() { - this.buildTreeOptions(); - }, + handler: 'updateValue', deep: true, immediate: true, }, + customValue: { + handler: 'updateValue', + deep: true, + }, options: { handler() { this.buildTreeOptions(); @@ -202,6 +204,10 @@ export default { }, methods: { getTreeClass, + updateValue() { + this.innerValue = coalesce(this.value, this.dataValue, this.defaultValue); + this.buildTreeOptions(); + }, buildTreeOptions() { const { options, innerValue: value, customValue, multiple, keys } = this; From 7644e17aa12e4132ecef5433016c997362fb188d Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:00:08 +0800 Subject: [PATCH 3/3] fix(TreeSelect): clear rendered columns when options become empty --- .../__test__/empty-options.test.js | 77 +++++++++++++++++++ .../components/tree-select/tree-select.ts | 5 +- .../tree-select/tree-select.vue | 5 +- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/components/tree-select/__test__/empty-options.test.js diff --git a/packages/components/tree-select/__test__/empty-options.test.js b/packages/components/tree-select/__test__/empty-options.test.js new file mode 100644 index 0000000000..5899e9f101 --- /dev/null +++ b/packages/components/tree-select/__test__/empty-options.test.js @@ -0,0 +1,77 @@ +import path from 'path'; +import simulate from 'miniprogram-simulate'; + +describe('TreeSelect empty options', () => { + const id = load(path.resolve(__dirname, '../tree-select')); + const options = [ + { label: 'First parent', value: 1, children: [{ label: 'First leaf', value: 11 }] }, + { label: 'Second parent', value: 2, children: [{ label: 'Second leaf', value: 22 }] }, + ]; + let component; + + function render(props = {}) { + component = simulate.render(id, { options, ...props }); + component.attach(document.createElement('parent-wrapper')); + return component; + } + + afterEach(() => component?.detach()); + + it.each([ + ['value', { value: [2, 22] }], + ['customValue', { value: [1, 11], customValue: [2, 22] }], + ['multiple', { value: [2, [22]], multiple: true }], + ['defaultValue', { defaultValue: [2, 22] }], + ])('removes rendered columns when %s options become empty', async (_, props) => { + render(props); + expect(component.dom.textContent).toContain('Second leaf'); + const selected = JSON.parse(JSON.stringify(component.data.innerValue)); + const change = jest.fn(); + component.addEventListener('change', change); + component.setData({ options: [] }); + await simulate.sleep(10); + expect(component.data.treeOptions).toEqual([]); + expect(component.dom.textContent).not.toContain('parent'); + expect(component.dom.textContent).not.toContain('leaf'); + expect(component.data.innerValue).toEqual(selected); + expect(change).not.toHaveBeenCalled(); + }); + + it('clears columns with custom keys', async () => { + render({ + options: [{ name: 'Named parent', id: 2, nodes: [{ name: 'Named leaf', id: 22 }] }], + keys: { label: 'name', value: 'id', children: 'nodes' }, + value: [2, 22], + }); + expect(component.dom.textContent).toContain('Named leaf'); + component.setData({ options: [] }); + await simulate.sleep(10); + expect(component.data.treeOptions).toEqual([]); + expect(component.dom.textContent).not.toContain('Named'); + }); + + it('renders no options when initially empty', () => { + render({ options: [], value: [2, 22] }); + expect(component.data.treeOptions || []).toEqual([]); + expect(component.dom.textContent.trim()).toBe(''); + }); + + it('restores the selected branch when options return', async () => { + render({ value: [2, 22] }); + component.setData({ options: [] }); + await simulate.sleep(10); + component.setData({ options }); + await simulate.sleep(10); + expect(component.data.innerValue).toEqual([2, 22]); + expect(component.dom.textContent).toContain('Second leaf'); + expect(component.dom.textContent).not.toContain('First leaf'); + }); + + it('renders asynchronously supplied options', async () => { + render({ options: [], value: [2, 22] }); + component.setData({ options }); + await simulate.sleep(10); + expect(component.data.innerValue).toEqual([2, 22]); + expect(component.dom.textContent).toContain('Second leaf'); + }); +}); diff --git a/packages/components/tree-select/tree-select.ts b/packages/components/tree-select/tree-select.ts index e26837a857..660ff627cc 100644 --- a/packages/components/tree-select/tree-select.ts +++ b/packages/components/tree-select/tree-select.ts @@ -56,7 +56,10 @@ export default class TreeSelect extends SuperComponent { buildTreeOptions() { const { options, value, customValue, multiple, keys } = this.data; - if (!options.length) return; + if (!options.length) { + this.setData({ treeOptions: [] }); + return; + } const treeOptions: TreeOptionData[][] = []; diff --git a/packages/uniapp-components/tree-select/tree-select.vue b/packages/uniapp-components/tree-select/tree-select.vue index d2417464f9..3cbf76865a 100644 --- a/packages/uniapp-components/tree-select/tree-select.vue +++ b/packages/uniapp-components/tree-select/tree-select.vue @@ -211,7 +211,10 @@ export default { buildTreeOptions() { const { options, innerValue: value, customValue, multiple, keys } = this; - if (!options.length) return; + if (!options.length) { + this.treeOptions = []; + return; + } const treeOptions = [];