@@ -462,4 +462,228 @@ describe('createClassyStore() — core reactivity', () => {
462462 expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ; // all batched
463463 } ) ;
464464 } ) ;
465+
466+ // ── Computed getter memoization ───────────────────────────────────────
467+
468+ describe ( 'computed getter memoization' , ( ) => {
469+ it ( 'memoizes getter result when deps have not changed' , ( ) => {
470+ let callCount = 0 ;
471+
472+ class Store {
473+ count = 5 ;
474+ get expensive ( ) {
475+ callCount ++ ;
476+ return this . count * 2 ;
477+ }
478+ }
479+
480+ const s = createClassyStore ( new Store ( ) ) ;
481+
482+ expect ( s . expensive ) . toBe ( 10 ) ;
483+ expect ( callCount ) . toBe ( 1 ) ;
484+
485+ // Accessing again without mutation should use cache
486+ expect ( s . expensive ) . toBe ( 10 ) ;
487+ expect ( callCount ) . toBe ( 1 ) ;
488+ } ) ;
489+
490+ it ( 'recomputes getter when dependency changes' , async ( ) => {
491+ let callCount = 0 ;
492+
493+ class Store {
494+ count = 5 ;
495+ get doubled ( ) {
496+ callCount ++ ;
497+ return this . count * 2 ;
498+ }
499+ }
500+
501+ const s = createClassyStore ( new Store ( ) ) ;
502+ expect ( s . doubled ) . toBe ( 10 ) ;
503+ expect ( callCount ) . toBe ( 1 ) ;
504+
505+ s . count = 10 ;
506+ // Getter should recompute because count changed
507+ expect ( s . doubled ) . toBe ( 20 ) ;
508+ expect ( callCount ) . toBe ( 2 ) ;
509+ } ) ;
510+
511+ it ( 'getter that reads another getter (nested computed)' , ( ) => {
512+ class Store {
513+ count = 3 ;
514+ get doubled ( ) {
515+ return this . count * 2 ;
516+ }
517+ get quadrupled ( ) {
518+ return this . doubled * 2 ;
519+ }
520+ }
521+
522+ const s = createClassyStore ( new Store ( ) ) ;
523+ expect ( s . quadrupled ) . toBe ( 12 ) ;
524+
525+ s . count = 5 ;
526+ expect ( s . quadrupled ) . toBe ( 20 ) ;
527+ } ) ;
528+
529+ it ( 'getter with nested object dependency recomputes on child mutation' , async ( ) => {
530+ class Store {
531+ items = [ 1 , 2 , 3 ] ;
532+ get total ( ) {
533+ return this . items . reduce ( ( a : number , b : number ) => a + b , 0 ) ;
534+ }
535+ }
536+
537+ const s = createClassyStore ( new Store ( ) ) ;
538+ expect ( s . total ) . toBe ( 6 ) ;
539+
540+ s . items . push ( 4 ) ;
541+ expect ( s . total ) . toBe ( 10 ) ;
542+ } ) ;
543+
544+ it ( 'getter invalidates when property is replaced entirely' , async ( ) => {
545+ class Store {
546+ data = { value : 1 } ;
547+ get label ( ) {
548+ return `value: ${ this . data . value } ` ;
549+ }
550+ }
551+
552+ const s = createClassyStore ( new Store ( ) ) ;
553+ expect ( s . label ) . toBe ( 'value: 1' ) ;
554+
555+ // Replace the entire object
556+ s . data = { value : 99 } ;
557+ expect ( s . label ) . toBe ( 'value: 99' ) ;
558+ } ) ;
559+ } ) ;
560+
561+ // ── Error handling ────────────────────────────────────────────────────
562+
563+ describe ( 'error handling' , ( ) => {
564+ it ( 'getInternal throws for a non-store object' , ( ) => {
565+ const plainObject = { count : 0 } ;
566+ expect ( ( ) => subscribe ( plainObject , ( ) => { } ) ) . toThrow (
567+ / n o t a s t o r e p r o x y / ,
568+ ) ;
569+ } ) ;
570+
571+ it ( 'getInternal throws for a primitive wrapper' , ( ) => {
572+ expect ( ( ) => subscribe ( { } as object , ( ) => { } ) ) . toThrow (
573+ / n o t a s t o r e p r o x y / ,
574+ ) ;
575+ } ) ;
576+
577+ it ( 'getVersion throws for a non-store object' , ( ) => {
578+ expect ( ( ) => getVersion ( { } ) ) . toThrow ( / n o t a s t o r e p r o x y / ) ;
579+ } ) ;
580+ } ) ;
581+
582+ // ── Child proxy management ────────────────────────────────────────────
583+
584+ describe ( 'child proxy management' , ( ) => {
585+ it ( 'replacing a nested object creates a new child proxy' , async ( ) => {
586+ const s = createClassyStore ( { nested : { a : 1 } } ) ;
587+ const listener = mock ( ( ) => { } ) ;
588+ subscribe ( s , listener ) ;
589+
590+ const oldRef = s . nested ;
591+ s . nested = { a : 2 } ;
592+ const newRef = s . nested ;
593+
594+ // Should be different proxy references
595+ expect ( oldRef ) . not . toBe ( newRef ) ;
596+ expect ( newRef . a ) . toBe ( 2 ) ;
597+
598+ await flush ( ) ;
599+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
600+ } ) ;
601+
602+ it ( 'mutations on old child proxy after replacement do not trigger notifications' , async ( ) => {
603+ const s = createClassyStore ( { nested : { a : 1 } } ) ;
604+ const listener = mock ( ( ) => { } ) ;
605+
606+ const oldNested = s . nested ; // get child proxy
607+ s . nested = { a : 2 } ; // replace — old child proxy detached
608+
609+ subscribe ( s , listener ) ;
610+
611+ // Mutate the old detached proxy reference (directly on target)
612+ // This shouldn't crash, but won't trigger listener on the store
613+ // because the child is no longer linked.
614+ // Note: old proxy still has its own internal, so mutations work on it
615+ // but the store's root won't be notified since the child is orphaned.
616+ oldNested . a = 999 ;
617+ await flush ( ) ;
618+
619+ // The store's nested should still be the new value
620+ expect ( s . nested . a ) . toBe ( 2 ) ;
621+ } ) ;
622+
623+ it ( 'deeply nested replacement triggers root listener' , async ( ) => {
624+ const s = createClassyStore ( {
625+ level1 : { level2 : { level3 : { value : 'deep' } } } ,
626+ } ) ;
627+ const listener = mock ( ( ) => { } ) ;
628+ subscribe ( s , listener ) ;
629+
630+ s . level1 . level2 . level3 . value = 'changed' ;
631+ await flush ( ) ;
632+
633+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ;
634+ expect ( s . level1 . level2 . level3 . value ) . toBe ( 'changed' ) ;
635+ } ) ;
636+ } ) ;
637+
638+ // ── Version tracking ──────────────────────────────────────────────────
639+
640+ describe ( 'version tracking' , ( ) => {
641+ it ( 'version does not change when same value is set' , ( ) => {
642+ const s = createClassyStore ( { count : 0 } ) ;
643+ const v1 = getVersion ( s ) ;
644+
645+ s . count = 0 ; // noop — same value
646+ const v2 = getVersion ( s ) ;
647+
648+ expect ( v2 ) . toBe ( v1 ) ;
649+ } ) ;
650+
651+ it ( 'version increments on nested mutation' , async ( ) => {
652+ const s = createClassyStore ( { nested : { value : 1 } } ) ;
653+ const v1 = getVersion ( s ) ;
654+
655+ s . nested . value = 2 ;
656+ const v2 = getVersion ( s ) ;
657+
658+ expect ( v2 ) . toBeGreaterThan ( v1 ) ;
659+ } ) ;
660+
661+ it ( 'version increments on delete' , async ( ) => {
662+ const s = createClassyStore ( { a : 1 , b : 2 } as Record < string , number > ) ;
663+ const v1 = getVersion ( s ) ;
664+
665+ delete s . b ;
666+ const v2 = getVersion ( s ) ;
667+
668+ expect ( v2 ) . toBeGreaterThan ( v1 ) ;
669+ } ) ;
670+
671+ it ( 'multiple rapid mutations produce one notification but multiple version bumps' , async ( ) => {
672+ const s = createClassyStore ( { count : 0 } ) ;
673+ const listener = mock ( ( ) => { } ) ;
674+ subscribe ( s , listener ) ;
675+
676+ const v1 = getVersion ( s ) ;
677+ s . count = 1 ;
678+ s . count = 2 ;
679+ s . count = 3 ;
680+ const v2 = getVersion ( s ) ;
681+
682+ await flush ( ) ;
683+
684+ expect ( v2 ) . toBeGreaterThan ( v1 ) ;
685+ expect ( listener ) . toHaveBeenCalledTimes ( 1 ) ; // batched
686+ expect ( s . count ) . toBe ( 3 ) ;
687+ } ) ;
688+ } ) ;
465689} ) ;
0 commit comments