@@ -11,6 +11,7 @@ import {
1111 sharedSubscriptionsApi ,
1212 useSharedFunction ,
1313 useSharedState ,
14+ useSharedStateSelector ,
1415 useSharedSubscription
1516} from "../src" ;
1617import type { Subscriber , SubscriberEvents } from "../src/hooks/use-shared-subscription" ;
@@ -524,3 +525,138 @@ describe('useSharedSubscription', () => {
524525 expect ( clearedState . error ) . toBeUndefined ( ) ;
525526 } ) ;
526527} ) ;
528+
529+ describe ( 'useSharedStateSelector' , ( ) => {
530+ const initialState = { a : 1 , b : 2 , nested : { c : 'hello' } } ;
531+ const sharedObjectState = createSharedState ( initialState ) ;
532+
533+ it ( 'should select a slice of state and only re-render when that slice changes' , ( ) => {
534+ const renderSpyA = vi . fn ( ) ;
535+ const renderSpyB = vi . fn ( ) ;
536+
537+ const ComponentA = ( ) => {
538+ const a = useSharedStateSelector ( sharedObjectState , state => state . a ) ;
539+ renderSpyA ( ) ;
540+ return < span data-testid = "a-value" > { a } </ span > ;
541+ } ;
542+
543+ const ComponentB = ( ) => {
544+ const b = useSharedStateSelector ( sharedObjectState , state => state . b ) ;
545+ renderSpyB ( ) ;
546+ return < span data-testid = "b-value" > { b } </ span > ;
547+ } ;
548+
549+ const Controller = ( ) => {
550+ const [ state , setState ] = useSharedState ( sharedObjectState ) ;
551+ return (
552+ < div >
553+ < button onClick = { ( ) => setState ( s => ( { ...s , a : s . a + 1 } ) ) } > inc a</ button >
554+ < button onClick = { ( ) => setState ( s => ( { ...s , b : s . b + 1 } ) ) } > inc b</ button >
555+ < span data-testid = "full-state" > { JSON . stringify ( state ) } </ span >
556+ </ div >
557+ ) ;
558+ } ;
559+
560+ render (
561+ < >
562+ < ComponentA />
563+ < ComponentB />
564+ < Controller />
565+ </ >
566+ ) ;
567+
568+ // Initial render
569+ expect ( screen . getByTestId ( 'a-value' ) . textContent ) . toBe ( '1' ) ;
570+ expect ( screen . getByTestId ( 'b-value' ) . textContent ) . toBe ( '2' ) ;
571+ expect ( renderSpyA ) . toHaveBeenCalledTimes ( 1 ) ;
572+ expect ( renderSpyB ) . toHaveBeenCalledTimes ( 1 ) ;
573+
574+ // Update 'b', only ComponentB should re-render
575+ act ( ( ) => {
576+ fireEvent . click ( screen . getByText ( 'inc b' ) ) ;
577+ } ) ;
578+
579+ expect ( screen . getByTestId ( 'a-value' ) . textContent ) . toBe ( '1' ) ;
580+ expect ( screen . getByTestId ( 'b-value' ) . textContent ) . toBe ( '3' ) ;
581+ expect ( renderSpyA ) . toHaveBeenCalledTimes ( 1 ) ; // Should not re-render
582+ expect ( renderSpyB ) . toHaveBeenCalledTimes ( 2 ) ; // Should re-render
583+
584+ // Update 'a', only ComponentA should re-render
585+ act ( ( ) => {
586+ fireEvent . click ( screen . getByText ( 'inc a' ) ) ;
587+ } ) ;
588+
589+ expect ( screen . getByTestId ( 'a-value' ) . textContent ) . toBe ( '2' ) ;
590+ expect ( screen . getByTestId ( 'b-value' ) . textContent ) . toBe ( '3' ) ;
591+ expect ( renderSpyA ) . toHaveBeenCalledTimes ( 2 ) ; // Should re-render
592+ expect ( renderSpyB ) . toHaveBeenCalledTimes ( 2 ) ; // Should not re-render
593+ } ) ;
594+
595+ it ( 'should work with string keys' , ( ) => {
596+ const renderSpy = vi . fn ( ) ;
597+ const key = 'string-key-state' ;
598+ sharedStatesApi . set ( key , { val : 100 } ) ;
599+
600+ const SelectorComponent = ( ) => {
601+ const val = useSharedStateSelector < { val : number } , typeof key , number > ( key , state => state . val ) ;
602+ renderSpy ( ) ;
603+ return < span data-testid = "val" > { val } </ span > ;
604+ } ;
605+
606+ render ( < SelectorComponent /> ) ;
607+ expect ( screen . getByTestId ( 'val' ) . textContent ) . toBe ( '100' ) ;
608+ expect ( renderSpy ) . toHaveBeenCalledTimes ( 1 ) ;
609+
610+ // Update state
611+ act ( ( ) => {
612+ sharedStatesApi . set ( key , { val : 200 } ) ;
613+ } ) ;
614+
615+ expect ( screen . getByTestId ( 'val' ) . textContent ) . toBe ( '200' ) ;
616+ expect ( renderSpy ) . toHaveBeenCalledTimes ( 2 ) ;
617+ } ) ;
618+
619+ it ( 'should perform deep comparison correctly' , ( ) => {
620+ const renderSpy = vi . fn ( ) ;
621+ const nestedState = createSharedState ( { a : 1 , nested : { c : 'initial' } } ) ;
622+
623+ const NestedSelector = ( ) => {
624+ const nested = useSharedStateSelector ( nestedState , state => state . nested ) ;
625+ renderSpy ( ) ;
626+ return < span data-testid = "nested-c" > { nested . c } </ span > ;
627+ } ;
628+
629+ const Controller = ( ) => {
630+ const [ , setState ] = useSharedState ( nestedState ) ;
631+ return (
632+ < div >
633+ < button onClick = { ( ) => setState ( s => ( { ...s , a : s . a + 1 } ) ) } > update outer</ button >
634+ < button onClick = { ( ) => setState ( s => ( { ...s , nested : { c : 'updated' } } ) ) } > update inner</ button >
635+ </ div >
636+ ) ;
637+ } ;
638+
639+ render (
640+ < >
641+ < NestedSelector />
642+ < Controller />
643+ </ >
644+ ) ;
645+
646+ expect ( screen . getByTestId ( 'nested-c' ) . textContent ) . toBe ( 'initial' ) ;
647+ expect ( renderSpy ) . toHaveBeenCalledTimes ( 1 ) ;
648+
649+ // Update outer property, should not re-render because the selected object is deep-equal
650+ act ( ( ) => {
651+ fireEvent . click ( screen . getByText ( 'update outer' ) ) ;
652+ } ) ;
653+ expect ( renderSpy ) . toHaveBeenCalledTimes ( 1 ) ;
654+
655+ // Update inner property, should re-render
656+ act ( ( ) => {
657+ fireEvent . click ( screen . getByText ( 'update inner' ) ) ;
658+ } ) ;
659+ expect ( screen . getByTestId ( 'nested-c' ) . textContent ) . toBe ( 'updated' ) ;
660+ expect ( renderSpy ) . toHaveBeenCalledTimes ( 2 ) ;
661+ } ) ;
662+ } ) ;
0 commit comments