@@ -750,6 +750,192 @@ describe('persist()', () => {
750750 } ) ;
751751 } ) ;
752752
753+ // ── expireIn / TTL ─────────────────────────────────────────────────────
754+
755+ describe ( 'expireIn / TTL' , ( ) => {
756+ it ( 'hydrates normally when TTL has not elapsed' , async ( ) => {
757+ const storage = createMockStorage ( ) ;
758+ storage . data . set (
759+ 'test' ,
760+ JSON . stringify ( {
761+ version : 0 ,
762+ state : { count : 42 } ,
763+ expiresAt : Date . now ( ) + 60_000 ,
764+ } ) ,
765+ ) ;
766+
767+ const s = store ( { count : 0 } ) ;
768+ const handle = persist ( s , { name : 'test' , storage, expireIn : 60_000 } ) ;
769+ await handle . hydrated ;
770+
771+ expect ( s . count ) . toBe ( 42 ) ;
772+ expect ( handle . isExpired ) . toBe ( false ) ;
773+ } ) ;
774+
775+ it ( 'skips hydration when TTL has elapsed and sets isExpired' , async ( ) => {
776+ const storage = createMockStorage ( ) ;
777+ storage . data . set (
778+ 'test' ,
779+ JSON . stringify ( {
780+ version : 0 ,
781+ state : { count : 42 } ,
782+ expiresAt : Date . now ( ) - 1000 ,
783+ } ) ,
784+ ) ;
785+
786+ const s = store ( { count : 0 } ) ;
787+ const handle = persist ( s , { name : 'test' , storage, expireIn : 60_000 } ) ;
788+ await handle . hydrated ;
789+
790+ expect ( s . count ) . toBe ( 0 ) ; // not hydrated
791+ expect ( handle . isExpired ) . toBe ( true ) ;
792+ } ) ;
793+
794+ it ( 'clearOnExpire: true removes the key from storage' , async ( ) => {
795+ const storage = createMockStorage ( ) ;
796+ storage . data . set (
797+ 'test' ,
798+ JSON . stringify ( {
799+ version : 0 ,
800+ state : { count : 42 } ,
801+ expiresAt : Date . now ( ) - 1000 ,
802+ } ) ,
803+ ) ;
804+
805+ const s = store ( { count : 0 } ) ;
806+ const handle = persist ( s , {
807+ name : 'test' ,
808+ storage,
809+ expireIn : 60_000 ,
810+ clearOnExpire : true ,
811+ } ) ;
812+ await handle . hydrated ;
813+ await tick ( ) ;
814+
815+ expect ( storage . data . has ( 'test' ) ) . toBe ( false ) ;
816+ } ) ;
817+
818+ it ( 'clearOnExpire: false (default) leaves the key in storage' , async ( ) => {
819+ const storage = createMockStorage ( ) ;
820+ storage . data . set (
821+ 'test' ,
822+ JSON . stringify ( {
823+ version : 0 ,
824+ state : { count : 42 } ,
825+ expiresAt : Date . now ( ) - 1000 ,
826+ } ) ,
827+ ) ;
828+
829+ const s = store ( { count : 0 } ) ;
830+ const handle = persist ( s , { name : 'test' , storage, expireIn : 60_000 } ) ;
831+ await handle . hydrated ;
832+ await tick ( ) ;
833+
834+ expect ( storage . data . has ( 'test' ) ) . toBe ( true ) ;
835+ } ) ;
836+
837+ it ( 'cross-tab sync rejects expired envelopes' , async ( ) => {
838+ const storage = createMockStorage ( ) ;
839+ const s = store ( { count : 0 } ) ;
840+ const handle = persist ( s , {
841+ name : 'test' ,
842+ storage,
843+ expireIn : 60_000 ,
844+ syncTabs : true ,
845+ } ) ;
846+ await handle . hydrated ;
847+
848+ const event = new StorageEvent ( 'storage' , {
849+ key : 'test' ,
850+ newValue : JSON . stringify ( {
851+ version : 0 ,
852+ state : { count : 999 } ,
853+ expiresAt : Date . now ( ) - 1000 ,
854+ } ) ,
855+ } ) ;
856+ globalThis . dispatchEvent ( event ) ;
857+
858+ expect ( s . count ) . toBe ( 0 ) ; // expired — rejected
859+ expect ( handle . isExpired ) . toBe ( true ) ;
860+
861+ handle . unsubscribe ( ) ;
862+ } ) ;
863+
864+ it ( 'data without expiresAt hydrates normally when expireIn is set' , async ( ) => {
865+ const storage = createMockStorage ( ) ;
866+ storage . data . set (
867+ 'test' ,
868+ JSON . stringify ( { version : 0 , state : { count : 77 } } ) ,
869+ ) ;
870+
871+ const s = store ( { count : 0 } ) ;
872+ const handle = persist ( s , { name : 'test' , storage, expireIn : 60_000 } ) ;
873+ await handle . hydrated ;
874+
875+ expect ( s . count ) . toBe ( 77 ) ;
876+ expect ( handle . isExpired ) . toBe ( false ) ;
877+ } ) ;
878+
879+ it ( 'TTL resets on every write (envelope timestamp refreshes)' , async ( ) => {
880+ const storage = createMockStorage ( ) ;
881+ const s = store ( { count : 0 } ) ;
882+ persist ( s , { name : 'test' , storage, expireIn : 30_000 } ) ;
883+
884+ const before = Date . now ( ) ;
885+ s . count = 1 ;
886+ await tick ( ) ;
887+
888+ const stored1 = parseStored ( storage , 'test' ) as unknown as {
889+ expiresAt : number ;
890+ } ;
891+ expect ( stored1 . expiresAt ) . toBeGreaterThanOrEqual ( before + 30_000 ) ;
892+
893+ // Second write should bump the timestamp.
894+ const betweenWrites = Date . now ( ) ;
895+ s . count = 2 ;
896+ await tick ( ) ;
897+
898+ const stored2 = parseStored ( storage , 'test' ) as unknown as {
899+ expiresAt : number ;
900+ } ;
901+ expect ( stored2 . expiresAt ) . toBeGreaterThanOrEqual ( betweenWrites + 30_000 ) ;
902+ expect ( stored2 . expiresAt ) . toBeGreaterThanOrEqual ( stored1 . expiresAt ) ;
903+ } ) ;
904+
905+ it ( 'rehydrate() re-checks expiry' , async ( ) => {
906+ const storage = createMockStorage ( ) ;
907+ // Start with valid data.
908+ storage . data . set (
909+ 'test' ,
910+ JSON . stringify ( {
911+ version : 0 ,
912+ state : { count : 42 } ,
913+ expiresAt : Date . now ( ) + 60_000 ,
914+ } ) ,
915+ ) ;
916+
917+ const s = store ( { count : 0 } ) ;
918+ const handle = persist ( s , { name : 'test' , storage, expireIn : 60_000 } ) ;
919+ await handle . hydrated ;
920+ expect ( s . count ) . toBe ( 42 ) ;
921+ expect ( handle . isExpired ) . toBe ( false ) ;
922+
923+ // Simulate data becoming expired.
924+ storage . data . set (
925+ 'test' ,
926+ JSON . stringify ( {
927+ version : 0 ,
928+ state : { count : 99 } ,
929+ expiresAt : Date . now ( ) - 1000 ,
930+ } ) ,
931+ ) ;
932+
933+ await handle . rehydrate ( ) ;
934+ expect ( s . count ) . toBe ( 42 ) ; // unchanged — expired data not applied
935+ expect ( handle . isExpired ) . toBe ( true ) ;
936+ } ) ;
937+ } ) ;
938+
753939 // ── Edge cases ──────────────────────────────────────────────────────────
754940
755941 describe ( 'edge cases' , ( ) => {
0 commit comments