@@ -8,6 +8,7 @@ import useMergedState from '../src/hooks/useMergedState';
88import useMobile from '../src/hooks/useMobile' ;
99import useState from '../src/hooks/useState' ;
1010import useSyncState from '../src/hooks/useSyncState' ;
11+ import useControlledState from '../src/hooks/useControlledState' ;
1112
1213global . disableUseId = false ;
1314
@@ -317,6 +318,163 @@ describe('hooks', () => {
317318 } ) ;
318319 } ) ;
319320
321+ describe ( 'useControlledState' , ( ) => {
322+ const FC : React . FC < {
323+ value ?: string ;
324+ defaultValue ?: string | ( ( ) => string ) ;
325+ } > = props => {
326+ const { value, defaultValue } = props ;
327+ const [ val , setVal ] = useControlledState < string > (
328+ defaultValue ?? null ,
329+ value ,
330+ ) ;
331+ return (
332+ < >
333+ < input
334+ value = { val }
335+ onChange = { e => {
336+ setVal ( e . target . value ) ;
337+ } }
338+ />
339+ < span className = "txt" > { val } </ span >
340+ </ >
341+ ) ;
342+ } ;
343+
344+ it ( 'still control of to undefined' , ( ) => {
345+ const { container, rerender } = render ( < FC value = "test" /> ) ;
346+
347+ expect ( container . querySelector ( 'input' ) . value ) . toEqual ( 'test' ) ;
348+ expect ( container . querySelector ( '.txt' ) . textContent ) . toEqual ( 'test' ) ;
349+
350+ rerender ( < FC value = { undefined } /> ) ;
351+ expect ( container . querySelector ( 'input' ) . value ) . toEqual ( 'test' ) ;
352+ expect ( container . querySelector ( '.txt' ) . textContent ) . toEqual ( '' ) ;
353+ } ) ;
354+
355+ describe ( 'correct defaultValue' , ( ) => {
356+ it ( 'raw' , ( ) => {
357+ const { container } = render ( < FC defaultValue = "test" /> ) ;
358+
359+ expect ( container . querySelector ( 'input' ) . value ) . toEqual ( 'test' ) ;
360+ } ) ;
361+
362+ it ( 'func' , ( ) => {
363+ const { container } = render ( < FC defaultValue = { ( ) => 'bamboo' } /> ) ;
364+
365+ expect ( container . querySelector ( 'input' ) . value ) . toEqual ( 'bamboo' ) ;
366+ } ) ;
367+ } ) ;
368+
369+ it ( 'not rerender when setState as deps' , ( ) => {
370+ let renderTimes = 0 ;
371+
372+ const Test = ( ) => {
373+ const [ val , setVal ] = useControlledState ( 0 ) ;
374+
375+ React . useEffect ( ( ) => {
376+ renderTimes += 1 ;
377+ expect ( renderTimes < 10 ) . toBeTruthy ( ) ;
378+
379+ setVal ( 1 ) ;
380+ } , [ setVal ] ) ;
381+
382+ return < div > { val } </ div > ;
383+ } ;
384+
385+ const { container } = render ( < Test /> ) ;
386+ expect ( container . firstChild . textContent ) . toEqual ( '1' ) ;
387+ } ) ;
388+
389+ it ( 'React 18 should not reset to undefined' , ( ) => {
390+ const Demo = ( ) => {
391+ const [ val ] = useControlledState ( 33 , undefined ) ;
392+
393+ return < div > { val } </ div > ;
394+ } ;
395+
396+ const { container } = render (
397+ < React . StrictMode >
398+ < Demo />
399+ </ React . StrictMode > ,
400+ ) ;
401+
402+ expect ( container . querySelector ( 'div' ) . textContent ) . toEqual ( '33' ) ;
403+ } ) ;
404+
405+ it ( 'uncontrolled to controlled' , ( ) => {
406+ const Demo : React . FC < Readonly < { value ?: number } > > = ( { value } ) => {
407+ const [ mergedValue , setMergedValue ] = useControlledState < number > (
408+ ( ) => 233 ,
409+ value ,
410+ ) ;
411+
412+ return (
413+ < span
414+ onClick = { ( ) => {
415+ setMergedValue ( v => v + 1 ) ;
416+ setMergedValue ( v => v + 1 ) ;
417+ } }
418+ onMouseEnter = { ( ) => {
419+ setMergedValue ( 1 ) ;
420+ } }
421+ >
422+ { mergedValue }
423+ </ span >
424+ ) ;
425+ } ;
426+
427+ const { container, rerender } = render ( < Demo /> ) ;
428+ expect ( container . textContent ) . toEqual ( '233' ) ;
429+
430+ // Update value
431+ rerender ( < Demo value = { 1 } /> ) ;
432+ expect ( container . textContent ) . toEqual ( '1' ) ;
433+
434+ // Click update
435+ rerender ( < Demo value = { undefined } /> ) ;
436+ fireEvent . mouseEnter ( container . querySelector ( 'span' ) ) ;
437+ fireEvent . click ( container . querySelector ( 'span' ) ) ;
438+ expect ( container . textContent ) . toEqual ( '3' ) ;
439+ } ) ;
440+
441+ it ( 'should alway use option value' , ( ) => {
442+ const Test : React . FC < Readonly < { value ?: number } > > = ( { value } ) => {
443+ const [ mergedValue , setMergedValue ] = useControlledState < number > (
444+ undefined ,
445+ value ,
446+ ) ;
447+ return (
448+ < span
449+ onClick = { ( ) => {
450+ setMergedValue ( 12 ) ;
451+ } }
452+ >
453+ { mergedValue }
454+ </ span >
455+ ) ;
456+ } ;
457+
458+ const { container } = render ( < Test value = { 1 } /> ) ;
459+ fireEvent . click ( container . querySelector ( 'span' ) ) ;
460+
461+ expect ( container . textContent ) . toBe ( '1' ) ;
462+ } ) ;
463+
464+ it ( 'render once' , ( ) => {
465+ let count = 0 ;
466+
467+ const Demo : React . FC = ( ) => {
468+ const [ ] = useControlledState ( undefined ) ;
469+ count += 1 ;
470+ return null ;
471+ } ;
472+
473+ render ( < Demo /> ) ;
474+ expect ( count ) . toBe ( 1 ) ;
475+ } ) ;
476+ } ) ;
477+
320478 describe ( 'useLayoutEffect' , ( ) => {
321479 const FC : React . FC < Readonly < { defaultValue ?: string } > > = props => {
322480 const { defaultValue } = props ;
0 commit comments