Skip to content

Commit

Permalink
constant time lookup for primitive values without valueComparator
Browse files Browse the repository at this point in the history
  • Loading branch information
KaelWD committed Jan 30, 2025
1 parent f779d56 commit c4ba8bf
Show file tree
Hide file tree
Showing 8 changed files with 91 additions and 34 deletions.
1 change: 1 addition & 0 deletions packages/vuetify/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -346,7 +347,7 @@ export const VAutocomplete = genericComponent<new <
if (!item || item.props.disabled) return

if (props.multiple) {
const index = model.value.findIndex(selection => 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) {
Expand Down
5 changes: 3 additions & 2 deletions packages/vuetify/src/components/VCombobox/VCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -381,7 +382,7 @@ export const VCombobox = genericComponent<new <
if (!item || item.props.disabled) return

if (props.multiple) {
const index = model.value.findIndex(selection => 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) {
Expand Down Expand Up @@ -446,7 +447,7 @@ export const VCombobox = genericComponent<new <
watch(menu, () => {
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)
Expand Down
15 changes: 10 additions & 5 deletions packages/vuetify/src/components/VList/VList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -35,10 +44,6 @@ export interface InternalListItem<T = any> extends ListItem<T> {
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)
Expand Down
7 changes: 4 additions & 3 deletions packages/vuetify/src/components/VSelect/VSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,7 +178,7 @@ export const VSelect = genericComponent<new <

const displayItems = computed(() => {
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
})
Expand Down Expand Up @@ -262,7 +263,7 @@ export const VSelect = genericComponent<new <
if (item.props.disabled) return

if (props.multiple) {
const index = model.value.findIndex(selection => 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) {
Expand Down Expand Up @@ -314,7 +315,7 @@ export const VSelect = genericComponent<new <
watch(menu, () => {
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)
Expand Down
81 changes: 63 additions & 18 deletions packages/vuetify/src/composables/list-items.ts
Original file line number Diff line number Diff line change
@@ -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<T = any> extends InternalItem<T> {
title: string
Expand All @@ -24,7 +24,7 @@ export interface ItemProps {
itemChildren: SelectItemKey
itemProps: SelectItemKey
returnObject: boolean
valueComparator: typeof deepEqual
valueComparator: typeof deepEqual | undefined
}

// Composables
Expand All @@ -50,10 +50,7 @@ export const makeItemsProps = propsFactory({
default: 'props',
},
returnObject: Boolean,
valueComparator: {
type: Function as PropType<typeof deepEqual>,
default: deepEqual,
},
valueComparator: Function as PropType<typeof deepEqual>,
}, 'list-items')

export function transformItem (props: Omit<ItemProps, 'items'>, item: any): ListItem {
Expand Down Expand Up @@ -84,10 +81,11 @@ export function transformItem (props: Omit<ItemProps, 'items'>, item: any): List
}

export function transformItems (props: Omit<ItemProps, 'items'>, 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
Expand All @@ -97,29 +95,76 @@ 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<Map<Primitive, ListItem[]>>(new Map())
const keylessItems = shallowRef<ListItem[]>([])
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

// 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
Expand Down
8 changes: 3 additions & 5 deletions packages/vuetify/src/composables/nested/selectStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -188,7 +186,7 @@ export const classicSelectStrategy = (mandatory?: boolean): SelectStrategy => {
map = strategy.select({
id,
value: true,
selected: new Map(map),
selected: map,
children,
parents,
})
Expand Down
5 changes: 5 additions & 0 deletions packages/vuetify/src/util/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

0 comments on commit c4ba8bf

Please sign in to comment.