diff --git a/packages/vuetify/.eslintrc.js b/packages/vuetify/.eslintrc.js index 78f656db0c4..22f0ed79e82 100644 --- a/packages/vuetify/.eslintrc.js +++ b/packages/vuetify/.eslintrc.js @@ -12,6 +12,7 @@ module.exports = { rules: { 'no-console': 'error', 'no-debugger': 'error', + 'no-labels': 'off', // 'vue/html-self-closing': 'off', // 'vue/html-closing-bracket-spacing': 'off', diff --git a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx index ea4ad190782..df611a5aa11 100644 --- a/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx +++ b/packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx @@ -28,6 +28,7 @@ import { makeTransitionProps } from '@/composables/transition' import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue' import { checkPrintable, + deepEqual, ensureValidVNode, genericComponent, IN_BROWSER, @@ -346,7 +347,7 @@ export const VAutocomplete = genericComponent props.valueComparator(selection.value, item.value)) + const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value)) const add = set == null ? !~index : set if (~index) { diff --git a/packages/vuetify/src/components/VCombobox/VCombobox.tsx b/packages/vuetify/src/components/VCombobox/VCombobox.tsx index 54014e7bc42..a386a3ce399 100644 --- a/packages/vuetify/src/components/VCombobox/VCombobox.tsx +++ b/packages/vuetify/src/components/VCombobox/VCombobox.tsx @@ -29,6 +29,7 @@ import { makeTransitionProps } from '@/composables/transition' import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue' import { checkPrintable, + deepEqual, ensureValidVNode, genericComponent, IN_BROWSER, @@ -381,7 +382,7 @@ export const VCombobox = genericComponent props.valueComparator(selection.value, item.value)) + const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value)) const add = set == null ? !~index : set if (~index) { @@ -446,7 +447,7 @@ export const VCombobox = genericComponent { if (!props.hideSelected && menu.value && model.value.length) { const index = displayItems.value.findIndex( - item => model.value.some(s => props.valueComparator(s.value, item.value)) + item => model.value.some(s => (props.valueComparator || deepEqual)(s.value, item.value)) ) IN_BROWSER && window.requestAnimationFrame(() => { index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index) diff --git a/packages/vuetify/src/components/VList/VList.tsx b/packages/vuetify/src/components/VList/VList.tsx index c5cc5d8b490..dcd6c704728 100644 --- a/packages/vuetify/src/components/VList/VList.tsx +++ b/packages/vuetify/src/components/VList/VList.tsx @@ -23,7 +23,16 @@ import { makeVariantProps } from '@/composables/variant' // Utilities import { computed, ref, shallowRef, toRef } from 'vue' -import { EventProp, focusChild, genericComponent, getPropertyFromItem, omit, propsFactory, useRender } from '@/util' +import { + EventProp, + focusChild, + genericComponent, + getPropertyFromItem, + isPrimitive, + omit, + propsFactory, + useRender, +} from '@/util' // Types import type { PropType } from 'vue' @@ -35,10 +44,6 @@ export interface InternalListItem extends ListItem { type?: 'item' | 'subheader' | 'divider' } -function isPrimitive (value: unknown): value is string | number | boolean { - return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' -} - function transformItem (props: ItemProps & { itemType?: string }, item: any): InternalListItem { const type = getPropertyFromItem(item, props.itemType, 'item') const title = isPrimitive(item) ? item : getPropertyFromItem(item, props.itemTitle) diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx index 88392f8d60b..65832dcc3f9 100644 --- a/packages/vuetify/src/components/VSelect/VSelect.tsx +++ b/packages/vuetify/src/components/VSelect/VSelect.tsx @@ -27,6 +27,7 @@ import { makeTransitionProps } from '@/composables/transition' import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue' import { checkPrintable, + deepEqual, ensureValidVNode, genericComponent, IN_BROWSER, @@ -177,7 +178,7 @@ export const VSelect = genericComponent { if (props.hideSelected) { - return items.value.filter(item => !model.value.some(s => props.valueComparator(s, item))) + return items.value.filter(item => !model.value.some(s => (props.valueComparator || deepEqual)(s, item))) } return items.value }) @@ -262,7 +263,7 @@ export const VSelect = genericComponent props.valueComparator(selection.value, item.value)) + const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value)) const add = set == null ? !~index : set if (~index) { @@ -314,7 +315,7 @@ export const VSelect = genericComponent { if (!props.hideSelected && menu.value && model.value.length) { const index = displayItems.value.findIndex( - item => model.value.some(s => props.valueComparator(s.value, item.value)) + item => model.value.some(s => (props.valueComparator || deepEqual)(s.value, item.value)) ) IN_BROWSER && window.requestAnimationFrame(() => { index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index) diff --git a/packages/vuetify/src/composables/list-items.ts b/packages/vuetify/src/composables/list-items.ts index 41d688a5416..d4f11fe5757 100644 --- a/packages/vuetify/src/composables/list-items.ts +++ b/packages/vuetify/src/composables/list-items.ts @@ -1,11 +1,11 @@ // Utilities -import { computed } from 'vue' -import { deepEqual, getPropertyFromItem, omit, propsFactory } from '@/util' +import { computed, shallowRef, toRaw, watchEffect } from 'vue' +import { deepEqual, getPropertyFromItem, isPrimitive, omit, propsFactory } from '@/util' // Types import type { PropType } from 'vue' import type { InternalItem } from '@/composables/filter' -import type { SelectItemKey } from '@/util' +import type { Primitive, SelectItemKey } from '@/util' export interface ListItem extends InternalItem { title: string @@ -24,7 +24,7 @@ export interface ItemProps { itemChildren: SelectItemKey itemProps: SelectItemKey returnObject: boolean - valueComparator: typeof deepEqual + valueComparator: typeof deepEqual | undefined } // Composables @@ -50,10 +50,7 @@ export const makeItemsProps = propsFactory({ default: 'props', }, returnObject: Boolean, - valueComparator: { - type: Function as PropType, - default: deepEqual, - }, + valueComparator: Function as PropType, }, 'list-items') export function transformItem (props: Omit, item: any): ListItem { @@ -84,10 +81,11 @@ export function transformItem (props: Omit, item: any): List } export function transformItems (props: Omit, items: ItemProps['items']) { - const array: ListItem[] = [] + const _props = toRaw(props) + const array: ListItem[] = [] for (const item of items) { - array.push(transformItem(props, item)) + array.push(transformItem(_props, item)) } return array @@ -97,15 +95,44 @@ export function useItems (props: ItemProps) { const items = computed(() => transformItems(props, props.items)) const hasNullItem = computed(() => items.value.some(item => item.value === null)) - function transformIn (value: any[]): ListItem[] { - // Cache unrefed values outside the loop + const itemsMap = shallowRef>(new Map()) + const keylessItems = shallowRef([]) + watchEffect(() => { const _items = items.value + const map = new Map() + const keyless = [] + for (let i = 0; i < _items.length; i++) { + const item = _items[i] + if (isPrimitive(item.value) || item.value === null) { + let values = map.get(item.value) + if (!values) { + values = [] + map.set(item.value, values) + } + values.push(item) + } else { + keyless.push(item) + } + } + itemsMap.value = map + keylessItems.value = keyless + }) + + function transformIn (value: any[]): ListItem[] { + // Cache unrefed values outside the loop, + // proxy getters can be slow when you call them a billion times + const _value = toRaw(value) + const _items = itemsMap.value + const _allItems = items.value + const _keylessItems = keylessItems.value const _hasNullItem = hasNullItem.value const _returnObject = props.returnObject - const valueComparator = props.valueComparator + const hasValueComparator = !!props.valueComparator + const valueComparator = props.valueComparator || deepEqual + const _props = toRaw(props) const returnValue: ListItem[] = [] - for (const v of value) { + main: for (const v of _value) { // When the model value is null, return an InternalItem // based on null only if null is one of the items if (!_hasNullItem && v === null) continue @@ -113,13 +140,31 @@ export function useItems (props: ItemProps) { // String model value means value is a custom input value from combobox // Don't look up existing items if the model value is a string if (_returnObject && typeof v === 'string') { - returnValue.push(transformItem(props, v)) + returnValue.push(transformItem(_props, v)) + continue + } + + // Fast path, items with primitive values and no + // custom valueComparator can use a constant-time + // map lookup instead of searching the items array + const fastItems = _items.get(v) + + // Slow path, always use valueComparator. + // This is O(n^2) so we really don't want to + // do it for more than a couple hundred items. + if (hasValueComparator || !fastItems) { + for (const item of (hasValueComparator ? _allItems : _keylessItems)) { + if (valueComparator(v, item.value)) { + returnValue.push(item) + continue main + } + } + // Not an existing item, construct it from the model (#4000) + returnValue.push(transformItem(_props, v)) continue } - returnValue.push( - _items.find(item => valueComparator(v, item.value)) || transformItem(props, v) - ) + returnValue.push(...fastItems) } return returnValue diff --git a/packages/vuetify/src/composables/nested/selectStrategies.ts b/packages/vuetify/src/composables/nested/selectStrategies.ts index 5affb7e152b..9d9f90ede38 100644 --- a/packages/vuetify/src/composables/nested/selectStrategies.ts +++ b/packages/vuetify/src/composables/nested/selectStrategies.ts @@ -88,13 +88,11 @@ export const independentSingleSelectStrategy = (mandatory?: boolean): SelectStra return parentStrategy.select({ ...rest, id, selected: singleSelected }) }, in: (v, children, parents) => { - let map = new Map() - if (v?.length) { - map = parentStrategy.in(v.slice(0, 1), children, parents) + return parentStrategy.in(v.slice(0, 1), children, parents) } - return map + return new Map() }, out: (v, children, parents) => { return parentStrategy.out(v, children, parents) @@ -188,7 +186,7 @@ export const classicSelectStrategy = (mandatory?: boolean): SelectStrategy => { map = strategy.select({ id, value: true, - selected: new Map(map), + selected: map, children, parents, }) diff --git a/packages/vuetify/src/util/helpers.ts b/packages/vuetify/src/util/helpers.ts index 01e719fde54..19a08a709e9 100644 --- a/packages/vuetify/src/util/helpers.ts +++ b/packages/vuetify/src/util/helpers.ts @@ -772,3 +772,8 @@ export function checkPrintable (e: KeyboardEvent) { const noModifier = !e.ctrlKey && !e.metaKey && !e.altKey return isPrintableChar && noModifier } + +export type Primitive = string | number | boolean | symbol | bigint +export function isPrimitive (value: unknown): value is Primitive { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' +}