Skip to content

Commit 1127a55

Browse files
Warn when changing Combobox between controlled and uncontrolled (#1878)
1 parent 83a5f45 commit 1127a55

File tree

6 files changed

+177
-9
lines changed

6 files changed

+177
-9
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
name: CI
22

33
on:
4-
- push
5-
- pull_request
4+
push:
5+
branches: [main]
6+
pull_request:
67

78
concurrency:
89
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}

packages/@headlessui-react/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix `<Popover.Button as={Fragment} />` crash ([#1889](https://github.com/tailwindlabs/headlessui/pull/1889))
1313
- Expose `close` function for `Menu` and `Menu.Item` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
1414

15+
### Added
16+
17+
- Warn when changing components between controlled and uncontrolled ([#1878](https://github.com/tailwindlabs/headlessui/issues/1878))
18+
1519
## [1.7.3] - 2022-09-30
1620

1721
### Fixed

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

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { createElement, useState, useEffect } from 'react'
22
import { render } from '@testing-library/react'
33

44
import { Combobox } from './combobox'
5-
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
5+
import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
66
import {
77
click,
88
focus,
@@ -396,7 +396,7 @@ describe('Rendering', () => {
396396
'selecting an option puts the value into Combobox.Input when displayValue is not provided',
397397
suppressConsoleLogs(async () => {
398398
function Example() {
399-
let [value, setValue] = useState(undefined)
399+
let [value, setValue] = useState(null)
400400

401401
return (
402402
<Combobox value={value} onChange={setValue}>
@@ -430,7 +430,7 @@ describe('Rendering', () => {
430430
'selecting an option puts the display value into Combobox.Input when displayValue is provided',
431431
suppressConsoleLogs(async () => {
432432
function Example() {
433-
let [value, setValue] = useState(undefined)
433+
let [value, setValue] = useState(null)
434434

435435
return (
436436
<Combobox value={value} onChange={setValue}>
@@ -558,7 +558,7 @@ describe('Rendering', () => {
558558
'should be possible to override the `type` on the input',
559559
suppressConsoleLogs(async () => {
560560
function Example() {
561-
let [value, setValue] = useState(undefined)
561+
let [value, setValue] = useState(null)
562562

563563
return (
564564
<Combobox value={value} onChange={setValue}>
@@ -5155,7 +5155,7 @@ describe('Mouse interactions', () => {
51555155
)
51565156

51575157
it(
5158-
'should sync the input field correctly and reset it when resetting the value from outside',
5158+
'should sync the input field correctly and reset it when resetting the value from outside (to null)',
51595159
suppressConsoleLogs(async () => {
51605160
function Example() {
51615161
let [value, setValue] = useState<string | null>('bob')
@@ -5196,6 +5196,96 @@ describe('Mouse interactions', () => {
51965196
})
51975197
)
51985198

5199+
it(
5200+
'should warn when changing the combobox from uncontrolled to controlled',
5201+
mockingConsoleLogs(async (spy) => {
5202+
function Example() {
5203+
let [value, setValue] = useState<string | undefined>(undefined)
5204+
5205+
return (
5206+
<>
5207+
<Combobox value={value} onChange={setValue}>
5208+
<Combobox.Input onChange={NOOP} />
5209+
<Combobox.Button>Trigger</Combobox.Button>
5210+
<Combobox.Options>
5211+
<Combobox.Option value="alice">alice</Combobox.Option>
5212+
<Combobox.Option value="bob">bob</Combobox.Option>
5213+
<Combobox.Option value="charlie">charlie</Combobox.Option>
5214+
</Combobox.Options>
5215+
</Combobox>
5216+
<button onClick={() => setValue('bob')}>to controlled</button>
5217+
</>
5218+
)
5219+
}
5220+
5221+
// Render a uncontrolled combobox
5222+
render(<Example />)
5223+
5224+
// Change to an controlled combobox
5225+
await click(getByText('to controlled'))
5226+
5227+
// Make sure we get a warning
5228+
expect(spy).toBeCalledTimes(1)
5229+
expect(spy.mock.calls.map((args) => args[0])).toEqual([
5230+
'A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.',
5231+
])
5232+
5233+
// Render a fresh uncontrolled combobox
5234+
render(<Example />)
5235+
5236+
// Change to an controlled combobox
5237+
await click(getByText('to controlled'))
5238+
5239+
// We shouldn't have gotten another warning as we do not want to warn on every render
5240+
expect(spy).toBeCalledTimes(1)
5241+
})
5242+
)
5243+
5244+
it(
5245+
'should warn when changing the combobox from controlled to uncontrolled',
5246+
mockingConsoleLogs(async (spy) => {
5247+
function Example() {
5248+
let [value, setValue] = useState<string | undefined>('bob')
5249+
5250+
return (
5251+
<>
5252+
<Combobox value={value} onChange={setValue}>
5253+
<Combobox.Input onChange={NOOP} />
5254+
<Combobox.Button>Trigger</Combobox.Button>
5255+
<Combobox.Options>
5256+
<Combobox.Option value="alice">alice</Combobox.Option>
5257+
<Combobox.Option value="bob">bob</Combobox.Option>
5258+
<Combobox.Option value="charlie">charlie</Combobox.Option>
5259+
</Combobox.Options>
5260+
</Combobox>
5261+
<button onClick={() => setValue(undefined)}>to uncontrolled</button>
5262+
</>
5263+
)
5264+
}
5265+
5266+
// Render a controlled combobox
5267+
render(<Example />)
5268+
5269+
// Change to an uncontrolled combobox
5270+
await click(getByText('to uncontrolled'))
5271+
5272+
// Make sure we get a warning
5273+
expect(spy).toBeCalledTimes(1)
5274+
expect(spy.mock.calls.map((args) => args[0])).toEqual([
5275+
'A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.',
5276+
])
5277+
5278+
// Render a fresh controlled combobox
5279+
render(<Example />)
5280+
5281+
// Change to an uncontrolled combobox
5282+
await click(getByText('to uncontrolled'))
5283+
5284+
// We shouldn't have gotten another warning as we do not want to warn on every render
5285+
expect(spy).toBeCalledTimes(1)
5286+
})
5287+
)
5288+
51995289
it(
52005290
'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)',
52015291
suppressConsoleLogs(async () => {

packages/@headlessui-react/src/hooks/use-controllable.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react'
1+
import { useRef, useState } from 'react'
22
import { useEvent } from './use-event'
33

44
export function useControllable<T>(
@@ -7,7 +7,25 @@ export function useControllable<T>(
77
defaultValue?: T
88
) {
99
let [internalValue, setInternalValue] = useState(defaultValue)
10+
1011
let isControlled = controlledValue !== undefined
12+
let wasControlled = useRef(isControlled)
13+
let didWarnOnUncontrolledToControlled = useRef(false)
14+
let didWarnOnControlledToUncontrolled = useRef(false)
15+
16+
if (isControlled && !wasControlled.current && !didWarnOnUncontrolledToControlled.current) {
17+
didWarnOnUncontrolledToControlled.current = true
18+
wasControlled.current = isControlled
19+
console.error(
20+
'A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.'
21+
)
22+
} else if (!isControlled && wasControlled.current && !didWarnOnControlledToUncontrolled.current) {
23+
didWarnOnControlledToUncontrolled.current = true
24+
wasControlled.current = isControlled
25+
console.error(
26+
'A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.'
27+
)
28+
}
1129

1230
return [
1331
(isControlled ? controlledValue : internalValue)!,

packages/@headlessui-react/src/test-utils/suppress-console-logs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,16 @@ export function suppressConsoleLogs<T extends unknown[]>(
1515
}).finally(() => spy.mockRestore())
1616
}
1717
}
18+
19+
export function mockingConsoleLogs<T extends unknown[]>(
20+
cb: (spy: jest.SpyInstance, ...args: T) => unknown,
21+
type: FunctionPropertyNames<typeof globalThis.console> = 'error'
22+
) {
23+
return (...args: T) => {
24+
let spy = jest.spyOn(globalThis.console, type).mockImplementation(jest.fn())
25+
26+
return new Promise<unknown>((resolve, reject) => {
27+
Promise.resolve(cb(spy, ...args)).then(resolve, reject)
28+
}).finally(() => spy.mockRestore())
29+
}
30+
}

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5381,7 +5381,7 @@ describe('Mouse interactions', () => {
53815381
)
53825382

53835383
it(
5384-
'should sync the input field correctly and reset it when resetting the value from outside',
5384+
'should sync the input field correctly and reset it when resetting the value from outside (to null)',
53855385
suppressConsoleLogs(async () => {
53865386
renderTemplate({
53875387
template: html`
@@ -5417,6 +5417,48 @@ describe('Mouse interactions', () => {
54175417
})
54185418
)
54195419

5420+
it(
5421+
'should sync the input field correctly and reset it when resetting the value from outside (to undefined)',
5422+
suppressConsoleLogs(async () => {
5423+
renderTemplate({
5424+
template: html`
5425+
<Combobox v-model="value">
5426+
<ComboboxInput />
5427+
<ComboboxButton>Trigger</ComboboxButton>
5428+
<ComboboxOptions>
5429+
<ComboboxOption value="alice">alice</ComboboxOption>
5430+
<ComboboxOption value="bob">bob</ComboboxOption>
5431+
<ComboboxOption value="charlie">charlie</ComboboxOption>
5432+
</ComboboxOptions>
5433+
</Combobox>
5434+
<button @click="value = undefined">reset</button>
5435+
`,
5436+
setup: () => ({ value: ref('bob') }),
5437+
})
5438+
5439+
// Open combobox
5440+
await click(getComboboxButton())
5441+
5442+
// Verify the input has the selected value
5443+
expect(getComboboxInput()?.value).toBe('bob')
5444+
5445+
// Override the input by typing something
5446+
await type(word('alice'), getComboboxInput())
5447+
expect(getComboboxInput()?.value).toBe('alice')
5448+
5449+
// Select the option
5450+
await press(Keys.ArrowUp)
5451+
await press(Keys.Enter)
5452+
expect(getComboboxInput()?.value).toBe('alice')
5453+
5454+
// Reset from outside
5455+
await click(getByText('reset'))
5456+
5457+
// Verify the input is reset correctly
5458+
expect(getComboboxInput()?.value).toBe('')
5459+
})
5460+
)
5461+
54205462
it(
54215463
'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)',
54225464
suppressConsoleLogs(async () => {

0 commit comments

Comments
 (0)