diff --git a/coverage/lcov.info b/coverage/lcov.info index 06d91db..274d7b8 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -10,49 +10,49 @@ DA:98,1 DA:99,4 DA:100,2 DA:116,7 -DA:117,7 -DA:118,14 -DA:139,3 -DA:140,3 -DA:141,6 -DA:182,4 -DA:187,8 -DA:189,4 -DA:195,1 -DA:197,4 -DA:233,2 -DA:234,4 -DA:245,3 -DA:246,3 -DA:247,3 -DA:298,4 -DA:302,4 -DA:303,12 -DA:319,3 -DA:323,3 -DA:324,12 -DA:341,3 -DA:346,3 -DA:347,12 -DA:348,3 -DA:370,3 -DA:376,3 -DA:377,12 -DA:378,6 -DA:402,3 -DA:409,3 -DA:410,12 -DA:411,9 -DA:469,5 -DA:473,10 -DA:484,1 -DA:485,1 -DA:486,1 -DA:490,1 -DA:491,1 -DA:492,3 -DA:493,3 -DA:494,1 +DA:118,7 +DA:119,14 +DA:141,3 +DA:143,3 +DA:144,6 +DA:186,4 +DA:192,8 +DA:194,4 +DA:200,1 +DA:202,4 +DA:238,3 +DA:239,6 +DA:250,3 +DA:251,3 +DA:252,3 +DA:304,4 +DA:309,4 +DA:310,12 +DA:327,3 +DA:332,3 +DA:333,12 +DA:351,3 +DA:357,3 +DA:358,12 +DA:359,3 +DA:382,3 +DA:389,3 +DA:390,12 +DA:391,6 +DA:416,3 +DA:424,3 +DA:425,12 +DA:426,9 +DA:485,5 +DA:489,10 +DA:500,1 +DA:501,1 +DA:502,1 +DA:506,1 +DA:507,1 +DA:508,3 +DA:509,3 +DA:510,1 LF:54 LH:54 end_of_record @@ -80,216 +80,294 @@ LF:19 LH:18 end_of_record SF:lib/src/functional_value_notifiers.dart -DA:14,7 -DA:21,8 -DA:27,7 -DA:29,21 -DA:30,7 -DA:33,7 +DA:16,7 +DA:23,12 +DA:29,7 +DA:31,21 +DA:32,7 DA:35,7 -DA:36,14 -DA:38,7 -DA:41,2 -DA:43,2 -DA:44,6 -DA:46,2 -DA:54,4 -DA:61,3 +DA:37,7 +DA:38,10 +DA:39,5 +DA:40,3 +DA:43,6 +DA:46,7 +DA:52,3 +DA:55,9 +DA:56,3 +DA:57,3 +DA:60,3 +DA:62,3 DA:63,6 -DA:64,9 -DA:65,6 -DA:66,3 -DA:69,3 -DA:76,7 -DA:83,7 -DA:85,14 -DA:86,28 -DA:88,7 -DA:95,4 -DA:102,4 -DA:104,8 -DA:105,12 -DA:106,8 -DA:109,4 -DA:117,2 -DA:124,2 -DA:126,3 -DA:127,2 -DA:128,1 -DA:129,5 -DA:131,2 -DA:136,3 -DA:142,3 -DA:144,5 -DA:145,8 -DA:147,3 -DA:160,4 -DA:169,0 -DA:173,4 -DA:177,4 -DA:178,24 -DA:179,8 -DA:180,8 -DA:181,4 -DA:184,4 -DA:188,4 -DA:189,12 -DA:191,4 -DA:194,1 -DA:196,1 -DA:197,3 -DA:198,3 -DA:200,1 -DA:219,3 -DA:228,0 -DA:232,3 -DA:237,11 +DA:64,3 +DA:68,2 +DA:70,2 +DA:71,6 +DA:73,2 +DA:81,4 +DA:88,3 +DA:90,6 +DA:91,9 +DA:92,6 +DA:93,3 +DA:96,3 +DA:103,7 +DA:110,7 +DA:112,14 +DA:113,28 +DA:115,7 +DA:122,4 +DA:129,4 +DA:131,8 +DA:132,12 +DA:133,8 +DA:136,4 +DA:144,3 +DA:151,3 +DA:153,5 +DA:154,4 +DA:155,2 +DA:156,2 +DA:157,2 +DA:158,6 +DA:161,3 +DA:164,1 +DA:166,2 +DA:167,1 +DA:172,3 +DA:174,3 +DA:176,5 +DA:177,8 +DA:179,3 +DA:193,4 +DA:202,9 +DA:206,4 +DA:210,4 +DA:211,24 +DA:212,8 +DA:213,8 +DA:214,4 +DA:217,4 +DA:221,4 +DA:222,9 +DA:223,3 +DA:224,1 +DA:225,2 +DA:228,4 +DA:231,2 +DA:233,2 +DA:234,4 +DA:235,6 +DA:236,6 +DA:237,2 DA:238,2 -DA:239,2 -DA:240,2 -DA:242,6 -DA:243,6 -DA:244,6 +DA:242,1 +DA:244,1 DA:245,3 -DA:248,3 -DA:252,3 -DA:253,12 -DA:255,3 -DA:258,1 -DA:260,1 -DA:261,3 -DA:262,3 -DA:263,3 -DA:265,1 -DA:286,3 -DA:296,0 -DA:297,0 -DA:298,0 -DA:299,0 -DA:300,0 -DA:305,3 -DA:311,11 -DA:312,2 -DA:313,2 +DA:246,3 +DA:248,1 +DA:268,3 +DA:277,8 +DA:281,3 +DA:286,11 +DA:287,2 +DA:288,2 +DA:289,2 +DA:291,6 +DA:292,6 +DA:293,6 +DA:294,3 +DA:297,3 +DA:301,3 +DA:302,12 +DA:303,3 +DA:304,1 +DA:305,2 +DA:308,3 +DA:311,1 +DA:313,1 DA:314,2 -DA:315,2 -DA:317,6 -DA:318,6 -DA:319,6 -DA:320,6 -DA:321,3 -DA:324,3 +DA:315,3 +DA:316,3 +DA:317,3 +DA:318,1 +DA:319,1 +DA:323,1 +DA:325,1 +DA:326,3 +DA:327,3 DA:328,3 -DA:329,3 -DA:330,3 -DA:331,3 -DA:332,3 -DA:333,3 -DA:336,3 -DA:339,1 -DA:341,1 -DA:342,3 -DA:343,3 -DA:344,3 -DA:345,3 -DA:347,1 -DA:370,3 -DA:381,0 -DA:382,0 -DA:383,0 -DA:384,0 -DA:385,0 -DA:386,0 -DA:391,3 -DA:398,11 -DA:399,2 -DA:400,2 -DA:401,2 -DA:402,2 +DA:330,1 +DA:352,3 +DA:362,2 +DA:363,2 +DA:364,2 +DA:365,2 +DA:366,2 +DA:371,3 +DA:377,11 +DA:378,2 +DA:379,2 +DA:380,2 +DA:381,2 +DA:383,6 +DA:384,6 +DA:385,6 +DA:386,6 +DA:387,3 +DA:390,3 +DA:394,3 +DA:395,3 +DA:396,3 +DA:397,3 +DA:398,3 +DA:399,3 +DA:401,3 +DA:402,1 DA:403,2 -DA:405,6 -DA:406,6 -DA:407,6 -DA:408,6 -DA:409,6 -DA:410,3 +DA:406,3 +DA:409,1 +DA:411,1 +DA:412,2 DA:413,3 -DA:417,3 -DA:418,3 -DA:419,3 -DA:420,3 -DA:421,3 -DA:422,3 -DA:423,3 +DA:414,3 +DA:415,3 +DA:416,3 +DA:417,1 +DA:418,1 +DA:422,1 +DA:424,1 +DA:425,3 DA:426,3 -DA:429,1 -DA:431,1 -DA:432,3 -DA:433,3 -DA:434,3 -DA:435,3 -DA:436,3 -DA:438,1 -DA:457,3 -DA:469,0 -DA:470,0 -DA:471,0 -DA:472,0 -DA:473,0 -DA:474,0 -DA:475,0 -DA:480,3 -DA:488,11 -DA:489,2 -DA:490,2 -DA:491,2 -DA:492,2 -DA:493,2 -DA:494,2 -DA:496,6 -DA:497,6 -DA:498,6 -DA:499,6 -DA:500,6 -DA:501,6 +DA:427,3 +DA:428,3 +DA:430,1 +DA:454,3 +DA:465,2 +DA:466,2 +DA:467,2 +DA:468,2 +DA:469,2 +DA:470,2 +DA:475,3 +DA:482,11 +DA:483,2 +DA:484,2 +DA:485,2 +DA:486,2 +DA:487,2 +DA:489,6 +DA:490,6 +DA:491,6 +DA:492,6 +DA:493,6 +DA:494,3 +DA:497,3 +DA:501,3 DA:502,3 +DA:503,3 +DA:504,3 DA:505,3 +DA:506,3 +DA:507,3 DA:509,3 -DA:510,3 -DA:511,3 -DA:512,3 -DA:513,3 +DA:510,1 +DA:511,2 DA:514,3 -DA:515,3 -DA:516,3 -DA:519,3 -DA:522,1 -DA:524,1 +DA:517,1 +DA:519,1 +DA:520,2 +DA:521,3 +DA:522,3 +DA:523,3 +DA:524,3 DA:525,3 -DA:526,3 -DA:527,3 -DA:528,3 -DA:529,3 -DA:530,3 -DA:532,1 -DA:540,5 -DA:545,5 -DA:547,5 -DA:549,15 -DA:550,5 -DA:551,9 -DA:552,5 -DA:553,2 -DA:555,5 -DA:557,11 -DA:558,5 -DA:561,2 -DA:563,1 -DA:565,1 -DA:566,3 -DA:568,1 -LF:208 -LH:188 +DA:526,1 +DA:527,1 +DA:531,1 +DA:533,1 +DA:534,3 +DA:535,3 +DA:536,3 +DA:537,3 +DA:538,3 +DA:540,1 +DA:560,3 +DA:572,2 +DA:573,2 +DA:574,2 +DA:575,2 +DA:576,2 +DA:577,2 +DA:578,2 +DA:583,3 +DA:591,11 +DA:592,2 +DA:593,2 +DA:594,2 +DA:595,2 +DA:596,2 +DA:597,2 +DA:599,6 +DA:600,6 +DA:601,6 +DA:602,6 +DA:603,6 +DA:604,6 +DA:605,3 +DA:608,3 +DA:612,3 +DA:613,3 +DA:614,3 +DA:615,3 +DA:616,3 +DA:617,3 +DA:618,3 +DA:619,3 +DA:621,3 +DA:622,1 +DA:623,2 +DA:626,3 +DA:629,1 +DA:631,1 +DA:632,2 +DA:633,3 +DA:634,3 +DA:635,3 +DA:636,3 +DA:637,3 +DA:638,3 +DA:639,1 +DA:640,1 +DA:644,1 +DA:646,1 +DA:647,3 +DA:648,3 +DA:649,3 +DA:650,3 +DA:651,3 +DA:652,3 +DA:654,1 +DA:662,5 +DA:667,5 +DA:669,5 +DA:671,20 +DA:672,9 +DA:673,5 +DA:674,4 +DA:675,5 +DA:677,11 +DA:678,5 +DA:681,4 +DA:683,1 +DA:685,3 +DA:686,1 +DA:689,1 +DA:691,1 +DA:692,3 +DA:694,1 +LF:286 +LH:286 end_of_record SF:lib/src/custom_value_notifier.dart DA:13,8 diff --git a/example/pubspec.lock b/example/pubspec.lock index 8407fe7..3d5e3d8 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -113,31 +113,31 @@ packages: path: ".." relative: true source: path - version: "5.3.3" + version: "5.3.5" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -195,10 +195,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" vector_math: dependency: transitive description: @@ -216,5 +216,5 @@ packages: source: hosted version: "15.0.2" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/functional_value_notifiers.dart b/lib/src/functional_value_notifiers.dart index c464a82..59ae86f 100644 --- a/lib/src/functional_value_notifiers.dart +++ b/lib/src/functional_value_notifiers.dart @@ -11,6 +11,8 @@ abstract class FunctionalValueNotifier extends ValueNotifier { @protected bool chainInitialized = false; + bool _resyncOnReattach = false; + FunctionalValueNotifier( super.initialValue, this.previousInChain, { @@ -34,10 +36,35 @@ abstract class FunctionalValueNotifier extends ValueNotifier { void addListener(VoidCallback listener) { if (!chainInitialized) { init(previousInChain); + if (_resyncOnReattach) { + _resyncOnReattach = false; + // Refresh the derived value from the source after having been detached, + // so a reused chain isn't stale once it becomes observed again. + internalHandler(); + } } super.addListener(listener); } + /// Detaches from the source. Symmetric to [setupChain]; called automatically + /// when the last listener is removed so an unobserved chain doesn't keep the + /// source subscription (and thus the source itself) alive. + @protected + @mustCallSuper + void teardownChain() { + previousInChain.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + teardownChain(); + } + } + @override void dispose() { if (chainInitialized) { @@ -126,18 +153,23 @@ class DebouncedValueNotifier extends FunctionalValueNotifier { internalHandler = () { debounceTimer?.cancel(); debounceTimer = // - Timer(debounceDuration, () => value = previousInChain.value); + Timer( + debounceDuration, + () => value = previousInChain.value, + ); }; setupChain(); } + + @override + void teardownChain() { + debounceTimer?.cancel(); + super.teardownChain(); + } } class AsyncValueNotifier extends FunctionalValueNotifier { - AsyncValueNotifier( - super.initialValue, - super.previousInChain, { - super.lazy, - }); + AsyncValueNotifier(super.initialValue, super.previousInChain, {super.lazy}); @override void init(ValueListenable previousInChain) { @@ -156,6 +188,7 @@ class CombiningValueNotifier extends ValueNotifier { final CombiningFunction2 combiner; late VoidCallback internalHandler; bool chainInitialized = false; + bool _resyncOnReattach = false; CombiningValueNotifier( super.initialValue, @@ -187,10 +220,25 @@ class CombiningValueNotifier extends ValueNotifier { /// set up so we don't have to do it again. if (!chainInitialized) { init(previousInChain1, previousInChain2); + if (_resyncOnReattach) { + _resyncOnReattach = false; + internalHandler(); + } } super.addListener(listener); } + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + previousInChain1.removeListener(internalHandler); + previousInChain2.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + } + @override void dispose() { if (chainInitialized) { @@ -215,6 +263,7 @@ class CombiningValueNotifier3 final CombiningFunction3 combiner; late VoidCallback internalHandler; bool chainInitialized = false; + bool _resyncOnReattach = false; CombiningValueNotifier3( super.initialValue, @@ -251,10 +300,26 @@ class CombiningValueNotifier3 /// set up so we don't have to do it again. if (!chainInitialized) { init(previousInChain1, previousInChain2, previousInChain3); + if (_resyncOnReattach) { + _resyncOnReattach = false; + internalHandler(); + } } super.addListener(listener); } + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + previousInChain1.removeListener(internalHandler); + previousInChain2.removeListener(internalHandler); + previousInChain3.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + } + @override void dispose() { if (chainInitialized) { @@ -282,6 +347,7 @@ class CombiningValueNotifier4 final CombiningFunction4 combiner; late VoidCallback internalHandler; bool chainInitialized = false; + bool _resyncOnReattach = false; CombiningValueNotifier4( super.initialValue, @@ -332,10 +398,27 @@ class CombiningValueNotifier4 previousInChain3, previousInChain4, ); + if (_resyncOnReattach) { + _resyncOnReattach = false; + internalHandler(); + } } super.addListener(listener); } + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + previousInChain1.removeListener(internalHandler); + previousInChain2.removeListener(internalHandler); + previousInChain3.removeListener(internalHandler); + previousInChain4.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + } + @override void dispose() { if (chainInitialized) { @@ -366,6 +449,7 @@ class CombiningValueNotifier5 final CombiningFunction5 combiner; late VoidCallback internalHandler; bool chainInitialized = false; + bool _resyncOnReattach = false; CombiningValueNotifier5( super.initialValue, @@ -422,10 +506,28 @@ class CombiningValueNotifier5 previousInChain4, previousInChain5, ); + if (_resyncOnReattach) { + _resyncOnReattach = false; + internalHandler(); + } } super.addListener(listener); } + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + previousInChain1.removeListener(internalHandler); + previousInChain2.removeListener(internalHandler); + previousInChain3.removeListener(internalHandler); + previousInChain4.removeListener(internalHandler); + previousInChain5.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + } + @override void dispose() { if (chainInitialized) { @@ -453,6 +555,7 @@ class CombiningValueNotifier6 final CombiningFunction6 combiner; late VoidCallback internalHandler; bool chainInitialized = false; + bool _resyncOnReattach = false; CombiningValueNotifier6( super.initialValue, @@ -515,10 +618,29 @@ class CombiningValueNotifier6 previousInChain5, previousInChain6, ); + if (_resyncOnReattach) { + _resyncOnReattach = false; + internalHandler(); + } } super.addListener(listener); } + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (chainInitialized && !hasListeners) { + previousInChain1.removeListener(internalHandler); + previousInChain2.removeListener(internalHandler); + previousInChain3.removeListener(internalHandler); + previousInChain4.removeListener(internalHandler); + previousInChain5.removeListener(internalHandler); + previousInChain6.removeListener(internalHandler); + chainInitialized = false; + _resyncOnReattach = true; + } + } + @override void dispose() { if (chainInitialized) { @@ -546,13 +668,11 @@ class MergingValueNotifiers extends FunctionalValueNotifier { @override void init(ValueListenable previousInChain) { - disposeFuncs = mergeWith.map( - (notifier) { - final notifyHandler = () => value = notifier.value; - notifier.addListener(notifyHandler); - return () => notifier.removeListener(notifyHandler); - }, - ).toList(); + disposeFuncs = mergeWith.map((notifier) { + final notifyHandler = () => value = notifier.value; + notifier.addListener(notifyHandler); + return () => notifier.removeListener(notifyHandler); + }).toList(); internalHandler = () => value = previousInChain.value; setupChain(); @@ -560,6 +680,12 @@ class MergingValueNotifiers extends FunctionalValueNotifier { void _callSelf(VoidCallback handler) => handler.call(); + @override + void teardownChain() { + disposeFuncs.forEach(_callSelf); + super.teardownChain(); + } + @override void dispose() { if (chainInitialized) { diff --git a/memory_test/pubspec.lock b/memory_test/pubspec.lock index addddeb..a92580f 100644 --- a/memory_test/pubspec.lock +++ b/memory_test/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -121,31 +121,31 @@ packages: path: ".." relative: true source: path - version: "5.3.4" + version: "5.3.5" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -203,10 +203,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" vector_math: dependency: transitive description: @@ -232,5 +232,5 @@ packages: source: hosted version: "2.2.0" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.lock b/pubspec.lock index d017d74..a8a0bc8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -103,26 +103,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" path: dependency: transitive description: @@ -180,10 +180,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" vector_math: dependency: transitive description: @@ -209,5 +209,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/test/chain_lifecycle_test.dart b/test/chain_lifecycle_test.dart index ea7ae77..54fbbd8 100644 --- a/test/chain_lifecycle_test.dart +++ b/test/chain_lifecycle_test.dart @@ -16,20 +16,24 @@ import 'package:watch_it/watch_it.dart'; /// - They are NOT recreated on each event /// - Same objects process all events throughout their lifetime /// -/// 2. **Chains are "hot" (eager), not "cold" (lazy)** -/// - Most operators call init() in their constructor -/// - Chains subscribe to their sources immediately upon creation -/// - This happens BEFORE any listener is added -/// - Chains stay subscribed even when they have zero listeners +/// 2. **Chains attach lazily and detach automatically** +/// - Eager operators call init() in their constructor, but a chain only +/// needs to stay subscribed to its source while it is itself observed +/// - When the last listener is removed, the chain detaches from its source +/// (symmetric to attaching) so it never keeps the source alive or leaks a +/// listener on it while unobserved +/// - While unobserved the chain does NOT track the source; its value is +/// refreshed from the source the moment it is observed again /// /// 3. **Resubscription works correctly** /// - You can remove all listeners and add new ones later -/// - The chain continues to track its source the whole time -/// - This was the reason for choosing hot subscription over lazy +/// - On re-attach the chain re-subscribes to the source AND resyncs its +/// value to the current source value, so it is never stale /// /// 4. **Memory management considerations** -/// - Chains without dispose() stay in memory and keep processing events -/// - Must call dispose() to unsubscribe from source and free memory +/// - A chain that has listeners keeps processing events; once its last +/// listener leaves it releases the source automatically +/// - dispose() is still available to tear a chain down explicitly /// - Variable assignment vs immediate .listen() creates different chain objects /// - But both behave identically in terms of lifecycle /// @@ -41,10 +45,10 @@ import 'package:watch_it/watch_it.dart'; /// /// ## Architecture Notes: /// -/// The "hot" subscription model was chosen to avoid a previous bug where -/// chains would not re-subscribe after all listeners were removed. This -/// ensures reliable resubscription behavior at the cost of keeping chains -/// active even without listeners. +/// Chains attach to their source on the first listener and detach on the last, +/// mirroring how a widget subscribes only while mounted. Re-attaching resyncs +/// the derived value from the source, so resubscription is reliable and no +/// listener is leaked on the source while the chain is unobserved. // Test subclasses that track constructor and handler calls // Global counter for ALL TrackedMapValueNotifier instances created @@ -319,6 +323,13 @@ class _TestRegisterHandlerInlineWidget extends WatchingWidget { } } +// A ValueNotifier that exposes whether it currently has any listeners, so tests +// can assert that derived chains attach/detach from their source correctly. +class _ObservableSource extends ValueNotifier { + _ObservableSource(super.value); + bool get hasAnyListeners => hasListeners; +} + // Test controller class that holds chains class TestController { final ValueNotifier source; @@ -480,22 +491,37 @@ void main() { source.dispose(); }); - test('chain stays subscribed even with zero listeners', () { - final source = ValueNotifier(0); + test('chain detaches from source when the last listener is removed', () { + final source = _ObservableSource(0); final chain = source.map((x) => x * 2); - // Add and then remove listener + // Add and then remove a listener. void listener() {} chain.addListener(listener); + expect(source.hasAnyListeners, isTrue); source.value = 5; expect(chain.value, 10); chain.removeListener(listener); - // Chain still updates even with no listeners! + // With zero listeners the chain detaches from the source (no leak) and + // stops tracking it. + expect(source.hasAnyListeners, isFalse); source.value = 7; - expect(chain.value, 14); // Chain still tracking source + expect(chain.value, 10); // unchanged while unobserved + // Re-attaching resubscribes AND resyncs to the current source value. + final seen = []; + void listener2() => seen.add(chain.value); + chain.addListener(listener2); + expect(source.hasAnyListeners, isTrue); + expect(chain.value, 14); // resynced on re-attach + + source.value = 9; + expect(chain.value, 18); + expect(seen, [18]); + + chain.removeListener(listener2); if (chain is FunctionalValueNotifier) { (chain as FunctionalValueNotifier).dispose(); } @@ -567,8 +593,9 @@ void main() { }); group('ValueListenableBuilder Integration Tests', () { - testWidgets('chain objects remain stable with ValueListenableBuilder', - (WidgetTester tester) async { + testWidgets('chain objects remain stable with ValueListenableBuilder', ( + WidgetTester tester, + ) async { final source = ValueNotifier(0); final chain = source.where((x) => x.isEven).map((x) => 'Value: $x'); @@ -615,8 +642,9 @@ void main() { source.dispose(); }); - testWidgets('chain with filter only rebuilds when condition passes', - (WidgetTester tester) async { + testWidgets('chain with filter only rebuilds when condition passes', ( + WidgetTester tester, + ) async { final source = ValueNotifier(0); final chain = source.where((x) => x.isEven); @@ -670,107 +698,109 @@ void main() { }); testWidgets( - 'chain created INSIDE builder recreates on each rebuild (anti-pattern)', - (WidgetTester tester) async { - final source = ValueNotifier(0); - final chainIdentities = []; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ValueListenableBuilder( - valueListenable: source, - builder: (context, value, child) { - // ANTI-PATTERN: Creating chain inside builder! - final chain = source.map((x) => x * 2); - chainIdentities.add(identityHashCode(chain)); - return Text('Value: ${chain.value}'); - }, + 'chain created INSIDE builder recreates on each rebuild (anti-pattern)', + (WidgetTester tester) async { + final source = ValueNotifier(0); + final chainIdentities = []; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: source, + builder: (context, value, child) { + // ANTI-PATTERN: Creating chain inside builder! + final chain = source.map((x) => x * 2); + chainIdentities.add(identityHashCode(chain)); + return Text('Value: ${chain.value}'); + }, + ), ), ), - ), - ); + ); - // Initial build creates first chain - expect(chainIdentities.length, 1); - final firstIdentity = chainIdentities[0]; + // Initial build creates first chain + expect(chainIdentities.length, 1); + final firstIdentity = chainIdentities[0]; - // Trigger rebuild - source.value = 1; - await tester.pump(); + // Trigger rebuild + source.value = 1; + await tester.pump(); - // New chain created! - expect(chainIdentities.length, 2); - expect(chainIdentities[1], isNot(firstIdentity)); // Different object! + // New chain created! + expect(chainIdentities.length, 2); + expect(chainIdentities[1], isNot(firstIdentity)); // Different object! - // Another rebuild - source.value = 2; - await tester.pump(); + // Another rebuild + source.value = 2; + await tester.pump(); - // Another new chain! - expect(chainIdentities.length, 3); - expect(chainIdentities[2], isNot(firstIdentity)); - expect(chainIdentities[2], isNot(chainIdentities[1])); + // Another new chain! + expect(chainIdentities.length, 3); + expect(chainIdentities[2], isNot(firstIdentity)); + expect(chainIdentities[2], isNot(chainIdentities[1])); - // All three identities are different - memory leak! - expect(chainIdentities.toSet().length, 3); + // All three identities are different - memory leak! + expect(chainIdentities.toSet().length, 3); - source.dispose(); - }); + source.dispose(); + }, + ); testWidgets( - 'chain created inline in valueListenable parameter (anti-pattern)', - (WidgetTester tester) async { - final source = ValueNotifier(0); - final chainIdentities = []; - int buildCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: _TestValueListenableBuilderInlineWidget( - source: source, - onBuild: (identity) { - buildCount++; - chainIdentities.add(identity); - }, + 'chain created inline in valueListenable parameter (anti-pattern)', + (WidgetTester tester) async { + final source = ValueNotifier(0); + final chainIdentities = []; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TestValueListenableBuilderInlineWidget( + source: source, + onBuild: (identity) { + buildCount++; + chainIdentities.add(identity); + }, + ), ), ), - ), - ); - - // Initial build - expect(buildCount, 1); - expect(chainIdentities.length, 1); - final firstIdentity = chainIdentities[0]; - expect(find.text('Value: 0'), findsOneWidget); - - // Fire events - each rebuild creates new chain - source.value = 5; - await tester.pump(); - - // New chain created! - expect(buildCount, 2); - expect(chainIdentities.length, 2); - expect(chainIdentities[1], isNot(firstIdentity)); // Different object! - expect(find.text('Value: 10'), findsOneWidget); - - // Another rebuild - source.value = 7; - await tester.pump(); - - // Another new chain! - expect(buildCount, 3); - expect(chainIdentities.length, 3); - expect(chainIdentities[2], isNot(firstIdentity)); - expect(chainIdentities[2], isNot(chainIdentities[1])); - expect(find.text('Value: 14'), findsOneWidget); - - // All three identities are different - memory leak! - expect(chainIdentities.toSet().length, 3); - - source.dispose(); - }); + ); + + // Initial build + expect(buildCount, 1); + expect(chainIdentities.length, 1); + final firstIdentity = chainIdentities[0]; + expect(find.text('Value: 0'), findsOneWidget); + + // Fire events - each rebuild creates new chain + source.value = 5; + await tester.pump(); + + // New chain created! + expect(buildCount, 2); + expect(chainIdentities.length, 2); + expect(chainIdentities[1], isNot(firstIdentity)); // Different object! + expect(find.text('Value: 10'), findsOneWidget); + + // Another rebuild + source.value = 7; + await tester.pump(); + + // Another new chain! + expect(buildCount, 3); + expect(chainIdentities.length, 3); + expect(chainIdentities[2], isNot(firstIdentity)); + expect(chainIdentities[2], isNot(chainIdentities[1])); + expect(find.text('Value: 14'), findsOneWidget); + + // All three identities are different - memory leak! + expect(chainIdentities.toSet().length, 3); + + source.dispose(); + }, + ); }); group('watch_it Integration Tests', () { @@ -781,8 +811,9 @@ void main() { } }); - testWidgets('watchValue with chain created outside build', - (WidgetTester tester) async { + testWidgets('watchValue with chain created outside build', ( + WidgetTester tester, + ) async { final source = ValueNotifier(0); final chain = source.map((x) => x * 2); final chainIdentity = identityHashCode(chain); @@ -836,8 +867,9 @@ void main() { source.dispose(); }); - testWidgets('watchValue with chain created INSIDE build (anti-pattern)', - (WidgetTester tester) async { + testWidgets('watchValue with chain created INSIDE build (anti-pattern)', ( + WidgetTester tester, + ) async { final source = ValueNotifier(0); // Register source in get_it so the widget can access it @@ -883,135 +915,138 @@ void main() { }); testWidgets( - 'watchValue with chain created inline - default caching prevents leak', - (WidgetTester tester) async { - final source = ValueNotifier(0); - _totalMapChainsCreated = 0; // Reset counter - - // Register source and model in get_it - di.registerSingleton>(source); - final model = ChainModel(source.map((x) => x * 2)); - di.registerSingleton(model); - - final capturedValues = []; - int buildCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: _TestWatchValueInlineWidget( - source: source, - onBuild: (value) { - buildCount++; - capturedValues.add(value); - }, - // allowObservableChange defaults to false - caching enabled + 'watchValue with chain created inline - default caching prevents leak', + (WidgetTester tester) async { + final source = ValueNotifier(0); + _totalMapChainsCreated = 0; // Reset counter + + // Register source and model in get_it + di.registerSingleton>(source); + final model = ChainModel(source.map((x) => x * 2)); + di.registerSingleton(model); + + final capturedValues = []; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TestWatchValueInlineWidget( + source: source, + onBuild: (value) { + buildCount++; + capturedValues.add(value); + }, + // allowObservableChange defaults to false - caching enabled + ), ), ), - ), - ); + ); + + // Initial build - one chain created + expect(buildCount, 1); + expect(capturedValues[0], 0); + expect( + _totalMapChainsCreated, + 1, + reason: 'First build creates one chain', + ); + + // Fire events - selector cached, NO new chains created + source.value = 5; + await tester.pump(); + expect(buildCount, 2); + expect(capturedValues[1], 10); + expect( + _totalMapChainsCreated, + 1, + reason: 'Caching prevents new chain creation', + ); + + source.value = 7; + await tester.pump(); + expect(buildCount, 3); + expect(capturedValues[2], 14); + expect( + _totalMapChainsCreated, + 1, + reason: 'Still only one chain - no memory leak!', + ); - // Initial build - one chain created - expect(buildCount, 1); - expect(capturedValues[0], 0); - expect( - _totalMapChainsCreated, - 1, - reason: 'First build creates one chain', - ); - - // Fire events - selector cached, NO new chains created - source.value = 5; - await tester.pump(); - expect(buildCount, 2); - expect(capturedValues[1], 10); - expect( - _totalMapChainsCreated, - 1, - reason: 'Caching prevents new chain creation', - ); - - source.value = 7; - await tester.pump(); - expect(buildCount, 3); - expect(capturedValues[2], 14); - expect( - _totalMapChainsCreated, - 1, - reason: 'Still only one chain - no memory leak!', - ); - - di.unregister(); - di.unregister>(); - source.dispose(); - }); + di.unregister(); + di.unregister>(); + source.dispose(); + }, + ); testWidgets( - 'watchValue with chain created inline + allowObservableChange=true - DOES leak', - (WidgetTester tester) async { - final source = ValueNotifier(0); - _totalMapChainsCreated = 0; // Reset counter - - // Register source and model in get_it - di.registerSingleton>(source); - final model = ChainModel(source.map((x) => x * 2)); - di.registerSingleton(model); - - final capturedValues = []; - int buildCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: _TestWatchValueInlineWidget( - source: source, - onBuild: (value) { - buildCount++; - capturedValues.add(value); - }, - allowObservableChange: true, // Disable caching - anti-pattern! + 'watchValue with chain created inline + allowObservableChange=true - DOES leak', + (WidgetTester tester) async { + final source = ValueNotifier(0); + _totalMapChainsCreated = 0; // Reset counter + + // Register source and model in get_it + di.registerSingleton>(source); + final model = ChainModel(source.map((x) => x * 2)); + di.registerSingleton(model); + + final capturedValues = []; + int buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TestWatchValueInlineWidget( + source: source, + onBuild: (value) { + buildCount++; + capturedValues.add(value); + }, + allowObservableChange: true, // Disable caching - anti-pattern! + ), ), ), - ), - ); - - // Initial build - one chain created - expect(buildCount, 1); - expect(capturedValues[0], 0); - expect( - _totalMapChainsCreated, - 1, - reason: 'First build creates one chain', - ); + ); + + // Initial build - one chain created + expect(buildCount, 1); + expect(capturedValues[0], 0); + expect( + _totalMapChainsCreated, + 1, + reason: 'First build creates one chain', + ); + + // Fire events - selector called every build, NEW chains created + source.value = 5; + await tester.pump(); + expect(buildCount, 2); + expect(capturedValues[1], 10); + expect( + _totalMapChainsCreated, + 2, + reason: 'Without caching, second build creates new chain', + ); + + source.value = 7; + await tester.pump(); + expect(buildCount, 3); + expect(capturedValues[2], 14); + expect( + _totalMapChainsCreated, + 3, + reason: 'Third build creates third chain - memory leak!', + ); - // Fire events - selector called every build, NEW chains created - source.value = 5; - await tester.pump(); - expect(buildCount, 2); - expect(capturedValues[1], 10); - expect( - _totalMapChainsCreated, - 2, - reason: 'Without caching, second build creates new chain', - ); - - source.value = 7; - await tester.pump(); - expect(buildCount, 3); - expect(capturedValues[2], 14); - expect( - _totalMapChainsCreated, - 3, - reason: 'Third build creates third chain - memory leak!', - ); - - di.unregister(); - di.unregister>(); - source.dispose(); - }); + di.unregister(); + di.unregister>(); + source.dispose(); + }, + ); - testWidgets('registerHandler with chain created outside', - (WidgetTester tester) async { + testWidgets('registerHandler with chain created outside', ( + WidgetTester tester, + ) async { final source = ValueNotifier(0); final chain = source.map((x) => x * 2); final chainIdentity = identityHashCode(chain); @@ -1071,88 +1106,89 @@ void main() { }); testWidgets( - 'registerHandler with inline chain + allowObservableChange=true - DOES leak when widget rebuilds', - (WidgetTester tester) async { - final source = ValueNotifier(0); - final rebuildTrigger = ValueNotifier(0); - _totalMapChainsCreated = 0; // Reset counter - - // Register model in get_it - final model = ChainModel(rebuildTrigger); - di.registerSingleton(model); - - int buildCount = 0; - final handlerValues = []; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: _TestRegisterHandlerInlineWidget( - source: source, - rebuildTrigger: rebuildTrigger, - onBuild: () { - buildCount++; - }, - onHandler: (value) { - handlerValues.add(value); - }, - allowObservableChange: true, // Disable caching - anti-pattern! + 'registerHandler with inline chain + allowObservableChange=true - DOES leak when widget rebuilds', + (WidgetTester tester) async { + final source = ValueNotifier(0); + final rebuildTrigger = ValueNotifier(0); + _totalMapChainsCreated = 0; // Reset counter + + // Register model in get_it + final model = ChainModel(rebuildTrigger); + di.registerSingleton(model); + + int buildCount = 0; + final handlerValues = []; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TestRegisterHandlerInlineWidget( + source: source, + rebuildTrigger: rebuildTrigger, + onBuild: () { + buildCount++; + }, + onHandler: (value) { + handlerValues.add(value); + }, + allowObservableChange: true, // Disable caching - anti-pattern! + ), ), ), - ), - ); - - // Initial build - expect(buildCount, 1); - expect( - _totalMapChainsCreated, - 1, - reason: 'First build creates one chain', - ); - - // Fire events - registerHandler doesn't cause rebuilds, just calls handler - source.value = 5; - await tester.pump(); - expect(handlerValues, [10]); // Handler called - expect(buildCount, 1); // Still no rebuild from registerHandler - expect( - _totalMapChainsCreated, - 1, - reason: 'No rebuild, so no new chain created', - ); - - // Now trigger a rebuild externally using rebuildTrigger - rebuildTrigger.value = 1; - await tester.pump(); - expect(buildCount, 2); // Rebuild happened! - expect( - _totalMapChainsCreated, - 2, - reason: 'Rebuild without caching creates new chain - leak!', - ); - - // Trigger another rebuild - rebuildTrigger.value = 2; - await tester.pump(); - expect(buildCount, 3); // Another rebuild - expect( - _totalMapChainsCreated, - 3, - reason: 'Third rebuild creates third chain - memory leak!', - ); - - // Fire source event - handler still works - source.value = 7; - await tester.pump(); - expect(handlerValues, [10, 14]); // Handler called - - // IMPORTANT: registerHandler with allowObservableChange=true causes leaks - // when widgets rebuild from ANY source (watchValue, setState, etc.) - - // Cleanup - di.unregister(); - source.dispose(); - rebuildTrigger.dispose(); - }); + ); + + // Initial build + expect(buildCount, 1); + expect( + _totalMapChainsCreated, + 1, + reason: 'First build creates one chain', + ); + + // Fire events - registerHandler doesn't cause rebuilds, just calls handler + source.value = 5; + await tester.pump(); + expect(handlerValues, [10]); // Handler called + expect(buildCount, 1); // Still no rebuild from registerHandler + expect( + _totalMapChainsCreated, + 1, + reason: 'No rebuild, so no new chain created', + ); + + // Now trigger a rebuild externally using rebuildTrigger + rebuildTrigger.value = 1; + await tester.pump(); + expect(buildCount, 2); // Rebuild happened! + expect( + _totalMapChainsCreated, + 2, + reason: 'Rebuild without caching creates new chain - leak!', + ); + + // Trigger another rebuild + rebuildTrigger.value = 2; + await tester.pump(); + expect(buildCount, 3); // Another rebuild + expect( + _totalMapChainsCreated, + 3, + reason: 'Third rebuild creates third chain - memory leak!', + ); + + // Fire source event - handler still works + source.value = 7; + await tester.pump(); + expect(handlerValues, [10, 14]); // Handler called + + // IMPORTANT: registerHandler with allowObservableChange=true causes leaks + // when widgets rebuild from ANY source (watchValue, setState, etc.) + + // Cleanup + di.unregister(); + source.dispose(); + rebuildTrigger.dispose(); + }, + ); }); } diff --git a/test/listenable_pipe_test.dart b/test/listenable_pipe_test.dart index ad7ec72..0c699d4 100644 --- a/test/listenable_pipe_test.dart +++ b/test/listenable_pipe_test.dart @@ -19,15 +19,24 @@ void main() { destListenable.removeListener(handler); + // With no listeners the chain detaches from its source and stops tracking + // it; the derived value is refreshed again on the next observation. sourceListenable.value = 4711; expect(destValue, '42'); + expect(destListenable.value, '42'); // unchanged while unobserved + + // Re-observing resubscribes and resyncs to the current source value. + destListenable.addListener(handler); expect(destListenable.value, '4711'); + + destListenable.removeListener(handler); }); test('Select Test', () { - final sourceListenable = - ValueNotifier(StringIntWrapper("fiz", 0)); + final sourceListenable = ValueNotifier( + StringIntWrapper("fiz", 0), + ); final stringDestListenable = sourceListenable.select((x) => x.s); String? stringDestValue; @@ -105,25 +114,26 @@ void main() { expect(destValue, 1); }); test( - 'Listen Test ChangeNotifier with internal cancel after first notification', - () { - final listenable = ChangeNotifier(); + 'Listen Test ChangeNotifier with internal cancel after first notification', + () { + final listenable = ChangeNotifier(); - int destValue = 0; - listenable.listen((subscription) { - destValue++; - if (destValue == 1) { - subscription.cancel(); - } - }); + int destValue = 0; + listenable.listen((subscription) { + destValue++; + if (destValue == 1) { + subscription.cancel(); + } + }); - listenable.notifyListeners(); - expect(destValue, 1); + listenable.notifyListeners(); + expect(destValue, 1); - listenable.notifyListeners(); + listenable.notifyListeners(); - expect(destValue, 1); - }); + expect(destValue, 1); + }, + ); test('Where Test', () { final listenable = ValueNotifier(0); @@ -189,27 +199,28 @@ void main() { }); test( - 'Where Test - no fallbackValue when initial does not match (backward compatible)', - () { - // Without fallback, initial value passes through even if it doesn't match - final listenable = ValueNotifier(5); // odd number + 'Where Test - no fallbackValue when initial does not match (backward compatible)', + () { + // Without fallback, initial value passes through even if it doesn't match + final listenable = ValueNotifier(5); // odd number - final filtered = listenable.where((x) => x.isEven); + final filtered = listenable.where((x) => x.isEven); - // No fallback, so use initial value even though it's odd (backward compatible) - expect(filtered.value, 5); + // No fallback, so use initial value even though it's odd (backward compatible) + expect(filtered.value, 5); - final destValues = []; - final subscription = filtered.listen((x, _) => destValues.add(x)); + final destValues = []; + final subscription = filtered.listen((x, _) => destValues.add(x)); - listenable.value = 42; // even - should pass - listenable.value = 43; // odd - should not pass - listenable.value = 44; // even - should pass + listenable.value = 42; // even - should pass + listenable.value = 43; // odd - should not pass + listenable.value = 44; // even - should pass - expect(destValues, [42, 44]); + expect(destValues, [42, 44]); - subscription.cancel(); - }); + subscription.cancel(); + }, + ); test('async Test', () async { final listenable = ValueNotifier(0); @@ -483,10 +494,7 @@ void main() { final listenable3 = ValueNotifier(0); final destValues = []; - final mergedListenable = listenable1.mergeWith([ - listenable2, - listenable3, - ]); + final mergedListenable = listenable1.mergeWith([listenable2, listenable3]); var subscription = mergedListenable.listen((x, _) { destValues.add(x); }); @@ -561,28 +569,8 @@ void main() { test('CustomValueNotifier with error in handler and error handler', () { Object? error; - final notifier = - CustomValueNotifier(4711, onError: (e, stackTrace) => error = e); - - notifier.addListener(() { - throw Exception('Error in handler'); - }); - - expect(notifier.value, 4711); - notifier.value = 4711; - expect(notifier.value, 4711); - expect(error, null); - notifier.value = 42; - expect(notifier.value, 42); - expect(error, isA()); - }); - test( - 'CustomValueNotifier async notification with error in handler and error handler', - () async { - Object? error; final notifier = CustomValueNotifier( 4711, - asyncNotification: true, onError: (e, stackTrace) => error = e, ); @@ -595,14 +583,39 @@ void main() { expect(notifier.value, 4711); expect(error, null); notifier.value = 42; - await Future.delayed(Duration.zero); expect(notifier.value, 42); expect(error, isA()); }); + test( + 'CustomValueNotifier async notification with error in handler and error handler', + () async { + Object? error; + final notifier = CustomValueNotifier( + 4711, + asyncNotification: true, + onError: (e, stackTrace) => error = e, + ); + + notifier.addListener(() { + throw Exception('Error in handler'); + }); + + expect(notifier.value, 4711); + notifier.value = 4711; + expect(notifier.value, 4711); + expect(error, null); + notifier.value = 42; + await Future.delayed(Duration.zero); + expect(notifier.value, 42); + expect(error, isA()); + }, + ); test('CustomValueNotifier a manual notify', () { - final notifier = - CustomValueNotifier(4711, mode: CustomNotifierMode.manual); + final notifier = CustomValueNotifier( + 4711, + mode: CustomNotifierMode.manual, + ); int val = 0; int callCount = 0; @@ -624,8 +637,10 @@ void main() { expect(callCount, 1); }); test('CustomValueNotifier always notify', () { - final notifier = - CustomValueNotifier(4711, mode: CustomNotifierMode.always); + final notifier = CustomValueNotifier( + 4711, + mode: CustomNotifierMode.always, + ); int val = 0; int callCount = 0; @@ -645,8 +660,10 @@ void main() { }); test('no double chain subscriptions', () { - final notifier = - CustomValueNotifier(0, mode: CustomNotifierMode.always); + final notifier = CustomValueNotifier( + 0, + mode: CustomNotifierMode.always, + ); int callCount = 0; notifier.listen((v, _) { callCount++; @@ -670,6 +687,166 @@ void main() { expect(mapCallCount, 1); expect(chainCallCount, 2); // 1 on init, 1 after notifier.value = 1; }); + + test('debounce detaches on last listener and resyncs on reattach', () async { + final source = ValueNotifier(0); + final debounced = source.debounce(const Duration(milliseconds: 10)); + + void listener() {} + debounced.addListener(listener); + + source.value = 1; + await Future.delayed(const Duration(milliseconds: 20)); + expect(debounced.value, 1); + + // Removing the last listener tears down the chain (cancels the timer). + debounced.removeListener(listener); + + source.value = 2; + await Future.delayed(const Duration(milliseconds: 20)); + // Detached while unobserved: value stays at the last observed value. + expect(debounced.value, 1); + + // Re-adding a listener reattaches and resyncs from the current source value + // (debounced, so after the timeout). + debounced.addListener(listener); + await Future.delayed(const Duration(milliseconds: 20)); + expect(debounced.value, 2); + + debounced.removeListener(listener); + source.dispose(); + }); + + test('combineLatest detaches on last listener and resyncs on reattach', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final combined = a.combineLatest(b, (x, y) => x + y); + + void listener() {} + combined.addListener(listener); + expect(combined.value, 3); + + combined.removeListener(listener); + + // Detached: source changes are ignored while unobserved. + a.value = 10; + expect(combined.value, 3); + + // Reattaching resyncs from the current source values. + combined.addListener(listener); + expect(combined.value, 12); + + combined.removeListener(listener); + }); + + test('combineLatest3 detaches on last listener and resyncs on reattach', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final c = ValueNotifier(3); + final combined = + a.combineLatest3(b, c, (x, y, z) => x + y + z); + + void listener() {} + combined.addListener(listener); + expect(combined.value, 6); + + combined.removeListener(listener); + + a.value = 10; + expect(combined.value, 6); + + combined.addListener(listener); + expect(combined.value, 15); + + combined.removeListener(listener); + }); + + test('combineLatest4 detaches on last listener and resyncs on reattach', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final c = ValueNotifier(3); + final d = ValueNotifier(4); + final combined = a.combineLatest4( + b, + c, + d, + (w, x, y, z) => w + x + y + z, + ); + + void listener() {} + combined.addListener(listener); + expect(combined.value, 10); + + combined.removeListener(listener); + + a.value = 10; + expect(combined.value, 10); + + combined.addListener(listener); + expect(combined.value, 19); + + combined.removeListener(listener); + }); + + test('combineLatest5 detaches on last listener and resyncs on reattach', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final c = ValueNotifier(3); + final d = ValueNotifier(4); + final e = ValueNotifier(5); + final combined = a.combineLatest5( + b, + c, + d, + e, + (v, w, x, y, z) => v + w + x + y + z, + ); + + void listener() {} + combined.addListener(listener); + expect(combined.value, 15); + + combined.removeListener(listener); + + a.value = 10; + expect(combined.value, 15); + + combined.addListener(listener); + expect(combined.value, 24); + + combined.removeListener(listener); + }); + + test('combineLatest6 detaches on last listener and resyncs on reattach', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final c = ValueNotifier(3); + final d = ValueNotifier(4); + final e = ValueNotifier(5); + final f = ValueNotifier(6); + final combined = a.combineLatest6( + b, + c, + d, + e, + f, + (u, v, w, x, y, z) => u + v + w + x + y + z, + ); + + void listener() {} + combined.addListener(listener); + expect(combined.value, 21); + + combined.removeListener(listener); + + a.value = 10; + expect(combined.value, 21); + + combined.addListener(listener); + expect(combined.value, 30); + + combined.removeListener(listener); + }); } class StringIntWrapper {