Skip to content

Commit a63ca93

Browse files
authored
Guarantee DOM sort order when performing actions (#1168)
* ensure proper sort order We already fixed a bug in the past where the order of DOM nodes wasn't stored in the correct order when performing operations (e.g.: using your keyboard to go to the next option). We fixed this by ensuring that when we register/unregister an option/item, that we sorted the list properly. This worked fine, until we introduced the Combobox components. This is because items in a Combobox are continuously filtered and because of that moved around. Moving a DOM node to a new position _doesn't_ require a full unmount/remount. This means that the sort gets messed up and the order is wrong when moving around again. To fix this, we will always perform a sort when performing actions. This could have performance drawbacks, but the alternative is to re-sort when the component gets updated. The bad part is that you can update a component via many ways (like changes on the parent), in those scenario's you probably don't care to properly re-order the internal list. Instead we do it while performing an action (`goToOption` / `goToItem`). To make things a bit more efficient, instead of querying the DOM all the time using `document.querySelectorAll`, we will keep track of the underlying DOM node instead. This does increase memory usage a bit but I think that this is a fine trade-off. Performance wise this could also be a bottleneck to perform the sorting if you have a lot of data. But this problem already exists today, therefore I consider this a complete new problem instead to solve. Maybe we don't solve it in Headless UI itself, but figure out a way to make it composable with existing virtualization libraries. * update changelog
1 parent ca56a15 commit a63ca93

File tree

12 files changed

+136
-116
lines changed

12 files changed

+136
-116
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
1414
- Improve SSR for `Tab` component ([#1155](https://github.com/tailwindlabs/headlessui/pull/1155))
1515
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
16+
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
1617

1718
## [Unreleased - @headlessui/vue]
1819

@@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2324
- Fix Dialog usage in Tabs ([#1149](https://github.com/tailwindlabs/headlessui/pull/1149))
2425
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
2526
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
27+
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
2628

2729
## [@headlessui/react@v1.5.0] - 2022-02-17
2830

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
3535
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3636
import { useLatestValue } from '../../hooks/use-latest-value'
3737
import { useTreeWalker } from '../../hooks/use-tree-walker'
38+
import { sortByDomNode } from '../../utils/focus-management'
3839

3940
enum ComboboxStates {
4041
Open,
@@ -50,6 +51,7 @@ type ComboboxOptionDataRef = MutableRefObject<{
5051
textValue?: string
5152
disabled: boolean
5253
value: unknown
54+
domRef: MutableRefObject<HTMLElement | null>
5355
}>
5456

5557
interface StateDefinition {
@@ -129,11 +131,14 @@ let reducers: {
129131
state.optionsRef.current &&
130132
!state.optionsPropsRef.current.static &&
131133
state.comboboxState === ComboboxStates.Closed
132-
)
134+
) {
133135
return state
136+
}
137+
138+
let options = sortByDomNode(state.options, (option) => option.dataRef.current.domRef.current)
134139

135140
let activeOptionIndex = calculateActiveIndex(action, {
136-
resolveItems: () => state.options,
141+
resolveItems: () => options,
137142
resolveActiveIndex: () => state.activeOptionIndex,
138143
resolveId: (item) => item.id,
139144
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -142,6 +147,7 @@ let reducers: {
142147
if (state.activeOptionIndex === activeOptionIndex) return state
143148
return {
144149
...state,
150+
options, // Sorted options
145151
activeOptionIndex,
146152
activationTrigger: action.trigger ?? ActivationTrigger.Other,
147153
}
@@ -150,17 +156,7 @@ let reducers: {
150156
let currentActiveOption =
151157
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
152158

153-
let orderMap = Array.from(
154-
state.optionsRef.current?.querySelectorAll('[id^="headlessui-combobox-option-"]')!
155-
).reduce(
156-
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
157-
{}
158-
) as Record<string, number>
159-
160-
let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort(
161-
(a, z) => orderMap[a.id] - orderMap[z.id]
162-
)
163-
159+
let options = [...state.options, { id: action.id, dataRef: action.dataRef }]
164160
let nextState = {
165161
...state,
166162
options,
@@ -859,8 +855,9 @@ let Option = forwardRefWithAs(function Option<
859855
let active =
860856
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
861857
let selected = state.comboboxPropsRef.current.value === value
862-
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value })
863-
let optionRef = useSyncRefs(ref)
858+
let internalOptionRef = useRef<HTMLLIElement | null>(null)
859+
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
860+
let optionRef = useSyncRefs(ref, internalOptionRef)
864861

865862
useIsoMorphicEffect(() => {
866863
bag.current.disabled = disabled

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { disposables } from '../../utils/disposables'
2929
import { Keys } from '../keyboard'
3030
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3131
import { isDisabledReactIssue7711 } from '../../utils/bugs'
32-
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
32+
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
3333
import { useWindowEvent } from '../../hooks/use-window-event'
3434
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
3535
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -48,6 +48,7 @@ type ListboxOptionDataRef = MutableRefObject<{
4848
textValue?: string
4949
disabled: boolean
5050
value: unknown
51+
domRef: MutableRefObject<HTMLElement | null>
5152
}>
5253

5354
interface StateDefinition {
@@ -126,8 +127,10 @@ let reducers: {
126127
if (state.disabled) return state
127128
if (state.listboxState === ListboxStates.Closed) return state
128129

130+
let options = sortByDomNode(state.options, (option) => option.dataRef.current.domRef.current)
131+
129132
let activeOptionIndex = calculateActiveIndex(action, {
130-
resolveItems: () => state.options,
133+
resolveItems: () => options,
131134
resolveActiveIndex: () => state.activeOptionIndex,
132135
resolveId: (item) => item.id,
133136
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -136,6 +139,7 @@ let reducers: {
136139
if (state.searchQuery === '' && state.activeOptionIndex === activeOptionIndex) return state
137140
return {
138141
...state,
142+
options, // Sorted options
139143
searchQuery: '',
140144
activeOptionIndex,
141145
activationTrigger: action.trigger ?? ActivationTrigger.Other,
@@ -180,17 +184,7 @@ let reducers: {
180184
return { ...state, searchQuery: '' }
181185
},
182186
[ActionTypes.RegisterOption]: (state, action) => {
183-
let orderMap = Array.from(
184-
state.optionsRef.current?.querySelectorAll('[id^="headlessui-listbox-option-"]')!
185-
).reduce(
186-
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
187-
{}
188-
) as Record<string, number>
189-
190-
let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort(
191-
(a, z) => orderMap[a.id] - orderMap[z.id]
192-
)
193-
187+
let options = [...state.options, { id: action.id, dataRef: action.dataRef }]
194188
return { ...state, options }
195189
},
196190
[ActionTypes.UnregisterOption]: (state, action) => {
@@ -665,9 +659,10 @@ let Option = forwardRefWithAs(function Option<
665659
let active =
666660
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
667661
let selected = state.propsRef.current.value === value
668-
let optionRef = useSyncRefs(ref)
662+
let internalOptionRef = useRef<HTMLElement | null>(null)
663+
let optionRef = useSyncRefs(ref, internalOptionRef)
669664

670-
let bag = useRef<ListboxOptionDataRef['current']>({ disabled, value })
665+
let bag = useRef<ListboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
671666

672667
useIsoMorphicEffect(() => {
673668
bag.current.disabled = disabled

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { useId } from '../../hooks/use-id'
3030
import { Keys } from '../keyboard'
3131
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3232
import { isDisabledReactIssue7711 } from '../../utils/bugs'
33-
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
33+
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
3434
import { useWindowEvent } from '../../hooks/use-window-event'
3535
import { useTreeWalker } from '../../hooks/use-tree-walker'
3636
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
@@ -46,7 +46,11 @@ enum ActivationTrigger {
4646
Other,
4747
}
4848

49-
type MenuItemDataRef = MutableRefObject<{ textValue?: string; disabled: boolean }>
49+
type MenuItemDataRef = MutableRefObject<{
50+
textValue?: string
51+
disabled: boolean
52+
domRef: MutableRefObject<HTMLElement | null>
53+
}>
5054

5155
interface StateDefinition {
5256
menuState: MenuStates
@@ -98,8 +102,10 @@ let reducers: {
98102
return { ...state, menuState: MenuStates.Open }
99103
},
100104
[ActionTypes.GoToItem]: (state, action) => {
105+
let items = sortByDomNode(state.items, (item) => item.dataRef.current.domRef.current)
106+
101107
let activeItemIndex = calculateActiveIndex(action, {
102-
resolveItems: () => state.items,
108+
resolveItems: () => items,
103109
resolveActiveIndex: () => state.activeItemIndex,
104110
resolveId: (item) => item.id,
105111
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -108,6 +114,7 @@ let reducers: {
108114
if (state.searchQuery === '' && state.activeItemIndex === activeItemIndex) return state
109115
return {
110116
...state,
117+
items, // Sorted items
111118
searchQuery: '',
112119
activeItemIndex,
113120
activationTrigger: action.trigger ?? ActivationTrigger.Other,
@@ -144,17 +151,7 @@ let reducers: {
144151
return { ...state, searchQuery: '', searchActiveItemIndex: null }
145152
},
146153
[ActionTypes.RegisterItem]: (state, action) => {
147-
let orderMap = Array.from(
148-
state.itemsRef.current?.querySelectorAll('[id^="headlessui-menu-item-"]')!
149-
).reduce(
150-
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
151-
{}
152-
) as Record<string, number>
153-
154-
let items = [...state.items, { id: action.id, dataRef: action.dataRef }].sort(
155-
(a, z) => orderMap[a.id] - orderMap[z.id]
156-
)
157-
154+
let items = [...state.items, { id: action.id, dataRef: action.dataRef }]
158155
return { ...state, items }
159156
},
160157
[ActionTypes.UnregisterItem]: (state, action) => {
@@ -560,7 +557,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
560557
let [state, dispatch] = useMenuContext('Menu.Item')
561558
let id = `headlessui-menu-item-${useId()}`
562559
let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false
563-
let itemRef = useSyncRefs(ref)
560+
let internalItemRef = useRef<HTMLElement | null>(null)
561+
let itemRef = useSyncRefs(ref, internalItemRef)
564562

565563
useIsoMorphicEffect(() => {
566564
if (state.menuState !== MenuStates.Open) return
@@ -573,7 +571,7 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
573571
return d.dispose
574572
}, [id, active, state.menuState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex])
575573

576-
let bag = useRef<MenuItemDataRef['current']>({ disabled })
574+
let bag = useRef<MenuItemDataRef['current']>({ disabled, domRef: internalItemRef })
577575

578576
useIsoMorphicEffect(() => {
579577
bag.current.disabled = disabled

packages/@headlessui-react/src/components/radio-group/radio-group.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { useId } from '../../hooks/use-id'
2020
import { match } from '../../utils/match'
2121
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2222
import { Keys } from '../../components/keyboard'
23-
import { focusIn, Focus, FocusResult } from '../../utils/focus-management'
23+
import { focusIn, Focus, FocusResult, sortByDomNode } from '../../utils/focus-management'
2424
import { useFlags } from '../../hooks/use-flags'
2525
import { Label, useLabels } from '../../components/label/label'
2626
import { Description, useDescriptions } from '../../components/description/description'
@@ -53,12 +53,14 @@ let reducers: {
5353
) => StateDefinition
5454
} = {
5555
[ActionTypes.RegisterOption](state, action) {
56+
let nextOptions = [
57+
...state.options,
58+
{ id: action.id, element: action.element, propsRef: action.propsRef },
59+
]
60+
5661
return {
5762
...state,
58-
options: [
59-
...state.options,
60-
{ id: action.id, element: action.element, propsRef: action.propsRef },
61-
],
63+
options: sortByDomNode(nextOptions, (option) => option.element.current),
6264
}
6365
},
6466
[ActionTypes.UnregisterOption](state, action) {

packages/@headlessui-react/src/utils/focus-management.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,27 @@ export function focusElement(element: HTMLElement | null) {
102102
element?.focus({ preventScroll: true })
103103
}
104104

105+
export function sortByDomNode<T>(
106+
nodes: T[],
107+
resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null
108+
): T[] {
109+
return nodes.slice().sort((aItem, zItem) => {
110+
let a = resolveKey(aItem)
111+
let z = resolveKey(zItem)
112+
113+
if (a === null || z === null) return 0
114+
115+
let position = a.compareDocumentPosition(z)
116+
117+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
118+
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
119+
return 0
120+
})
121+
}
122+
105123
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
106124
let elements = Array.isArray(container)
107-
? container.slice().sort((a, z) => {
108-
let position = a.compareDocumentPosition(z)
109-
110-
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
111-
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
112-
return 0
113-
})
125+
? sortByDomNode(container)
114126
: getFocusableElements(container)
115127
let active = document.activeElement as HTMLElement
116128

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open
2626
import { match } from '../../utils/match'
2727
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2828
import { useTreeWalker } from '../../hooks/use-tree-walker'
29+
import { sortByDomNode } from '../../utils/focus-management'
2930

3031
enum ComboboxStates {
3132
Open,
@@ -37,7 +38,11 @@ enum ActivationTrigger {
3738
Other,
3839
}
3940

40-
type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }>
41+
type ComboboxOptionDataRef = Ref<{
42+
disabled: boolean
43+
value: unknown
44+
domRef: Ref<HTMLElement | null>
45+
}>
4146
type StateDefinition = {
4247
// State
4348
comboboxState: Ref<ComboboxStates>
@@ -140,24 +145,27 @@ export let Combobox = defineComponent({
140145
optionsRef.value &&
141146
!optionsPropsRef.value.static &&
142147
comboboxState.value === ComboboxStates.Closed
143-
)
148+
) {
144149
return
150+
}
151+
152+
let orderedOptions = sortByDomNode(options.value, (option) => option.dataRef.domRef.value)
145153

146154
let nextActiveOptionIndex = calculateActiveIndex(
147155
focus === Focus.Specific
148156
? { focus: Focus.Specific, id: id! }
149157
: { focus: focus as Exclude<Focus, Focus.Specific> },
150158
{
151-
resolveItems: () => options.value,
159+
resolveItems: () => orderedOptions,
152160
resolveActiveIndex: () => activeOptionIndex.value,
153161
resolveId: (option) => option.id,
154162
resolveDisabled: (option) => option.dataRef.disabled,
155163
}
156164
)
157165

158-
if (activeOptionIndex.value === nextActiveOptionIndex) return
159166
activeOptionIndex.value = nextActiveOptionIndex
160167
activationTrigger.value = trigger ?? ActivationTrigger.Other
168+
options.value = orderedOptions
161169
},
162170
syncInputValue() {
163171
let value = api.value.value
@@ -189,17 +197,9 @@ export let Combobox = defineComponent({
189197
registerOption(id: string, dataRef: ComboboxOptionDataRef) {
190198
let currentActiveOption =
191199
activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null
192-
let orderMap = Array.from(
193-
optionsRef.value?.querySelectorAll('[id^="headlessui-combobox-option-"]') ?? []
194-
).reduce(
195-
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
196-
{}
197-
) as Record<string, number>
198200

199201
// @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }'
200-
options.value = [...options.value, { id, dataRef }].sort(
201-
(a, z) => orderMap[a.id] - orderMap[z.id]
202-
)
202+
options.value = [...options.value, { id, dataRef }]
203203

204204
// If we inserted an option before the current active option then the
205205
// active option index would be wrong. To fix this, we will re-lookup
@@ -630,6 +630,7 @@ export let ComboboxOption = defineComponent({
630630
setup(props, { slots, attrs }) {
631631
let api = useComboboxContext('ComboboxOption')
632632
let id = `headlessui-combobox-option-${useId()}`
633+
let internalOptionRef = ref<HTMLElement | null>(null)
633634

634635
let active = computed(() => {
635636
return api.activeOptionIndex.value !== null
@@ -642,6 +643,7 @@ export let ComboboxOption = defineComponent({
642643
let dataRef = computed<ComboboxOptionDataRef['value']>(() => ({
643644
disabled: props.disabled,
644645
value: props.value,
646+
domRef: internalOptionRef,
645647
}))
646648

647649
onMounted(() => api.registerOption(id, dataRef))
@@ -696,6 +698,7 @@ export let ComboboxOption = defineComponent({
696698
let slot = { active: active.value, selected: selected.value, disabled }
697699
let propsWeControl = {
698700
id,
701+
ref: internalOptionRef,
699702
role: 'option',
700703
tabIndex: disabled === true ? undefined : -1,
701704
'aria-disabled': disabled === true ? true : undefined,

0 commit comments

Comments
 (0)