@@ -379,10 +379,7 @@ TEST_F(UIDeclarativeForTest, Reactive_lists) { // HEADER_H3
379379}
380380
381381TEST_F (UIDeclarativeForTest, DynamicPerformance) {
382- struct State {
383- AProperty<AVector<AString>> items = AVector<AString> { " Hello" , " World" , " Test" };
384- };
385- auto state = _new<State>();
382+
386383
387384 ::testing::GTEST_FLAG (throw_on_failure) = true;
388385
@@ -392,18 +389,72 @@ TEST_F(UIDeclarativeForTest, DynamicPerformance) {
392389 EXPECT_CALL (mTestObserver , onViewCreated (" Test" _as));
393390 EXPECT_CALL (mTestObserver , onViewCreated (" Bruh" _as));
394391
392+ // ## View caching { #AFOREACHUI_CACHING }
393+ //
394+ // By default, AForEachUI does not cache instantiated views. This means that every time the underlying model is
395+ // changed, all views are destroyed and recreated. This is not efficient in many scenarios, especially when views are
396+ // complex and model changes are small.
397+ //
398+ // To enable view caching, you need to provide a *key function* that generates a unique key for each item in the
399+ // model. The key function is a lambda that takes an item and returns a `std::size_t` hash of the value.
400+ //
401+ // AUI_DOCS_CODE_BEGIN
402+ struct State {
403+ AProperty<AVector<AString>> items = AVector<AString> { " Hello" , " World" , " Test" };
404+ };
405+ auto state = _new<State>();
406+
407+ auto testObserver = &mTestObserver ;
408+
395409 mWindow ->setContents (Vertical {
396410 AScrollArea::Builder ()
397411 .withContents (
398- AUI_DECLARATIVE_FOR_EX (i, *state->items , AVerticalLayout, &) {
399- mTestObserver .onViewCreated (i);
412+ AUI_DECLARATIVE_FOR (i, *state->items , AVerticalLayout) {
413+ testObserver->onViewCreated (i); // HIDE
414+ ALogger::info (" Test" ) << " Created view: " << i;
400415 return Label { i };
416+ } AUI_LET {
417+ it->setKeyFunction ([](const AString& k) {
418+ return std::hash<AString>{}(k);
419+ });
401420 })
402421 .build () AUI_OVERRIDE_STYLE { FixedSize { 150_dp, 200_dp } },
403422 });
423+ // AUI_DOCS_CODE_END
424+ saveScreenshot (" 1" );
425+ //
426+ // <figure markdown="span">
427+ // { width="500" }
428+ // <figcaption>Basic view caching example.</figcaption>
429+ // </figure>
430+ //
431+ //
432+ // ``` title="Possible output"
433+ // [07:09:06][][Test][INFO]: Created view: Hello
434+ // [07:09:06][][Test][INFO]: Created view: World
435+ // [07:09:06][][Test][INFO]: Created view: Test
436+ // ```
404437
405438 uitest::frame ();
439+ //
440+ // Upon adding a new item to the model, only the new item's view is created. The existing views are reused from
441+ // the cache.
442+ // AUI_DOCS_CODE_BEGIN
406443 state->items .writeScope ()->push_back (" Bruh" );
444+ // AUI_DOCS_CODE_END
445+ //
446+ // If we were not using view caching, all views would be recreated:
447+ // ``` title="Possible output without view caching"
448+ // [07:09:06][][Test][INFO]: Created view: Hello
449+ // [07:09:06][][Test][INFO]: Created view: World
450+ // [07:09:06][][Test][INFO]: Created view: Test
451+ // [07:09:10][][Test][INFO]: Created view: Hello
452+ // [07:09:10][][Test][INFO]: Created view: World
453+ // [07:09:10][][Test][INFO]: Created view: Test
454+ // [07:09:10][][Test][INFO]: Created view: Bruh
455+ // ```
456+ //
457+
407458 uitest::frame ();
408459 EXPECT_EQ (cache<AForEachUI<AString>>().size (), 0 );
409460}
@@ -446,6 +497,12 @@ TEST_F(UIDeclarativeForTest, IntGrouping) {
446497TEST_F (UIDeclarativeForTest, IntGroupingDynamic1) {
447498 ::testing::GTEST_FLAG (throw_on_failure) = true;
448499
500+ //
501+ // ### Advanced grouping with caching
502+ //
503+ // In this example, we demonstrate a more advanced use case of AForEachUI with nested grouping and view caching.
504+ //
505+ // AUI_DOCS_CODE_BEGIN
449506 struct State {
450507 AProperty<AVector<int >> ints = AVector<int > { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 };
451508 };
@@ -471,11 +528,49 @@ TEST_F(UIDeclarativeForTest, IntGroupingDynamic1) {
471528 auto str = " {}" _format (i);
472529 mTestObserver .onViewCreated (str);
473530 return Label { std::move (str) };
531+ } AUI_LET {
532+ it->setKeyFunction ([](int k) {
533+ return k;
534+ });
474535 }
475536 };
537+ } AUI_LET {
538+ it->setKeyFunction ([](const auto & rng) {
539+ return aui::hash_range (ranges::begin (rng), ranges::end (rng));;
540+ });
476541 })
477542 .build () AUI_OVERRIDE_STYLE { FixedSize { 150_dp, 300_dp } },
478543 });
544+ // AUI_DOCS_CODE_END
545+ //
546+ // In this example, we have a nested AForEachUI structure, where the outer AForEachUI iterates over groups of
547+ // integers, and the inner AForEachUI iterates over individual integers within each group.
548+ //
549+ // <figure markdown="span">
550+ // { width="500" }
551+ // <figcaption>Advanced grouping example.</figcaption>
552+ // </figure>
553+ //
554+ // In addition to user-provided `setKeyFunction` outcome, the key is strengthened with byte-level hash of the item
555+ // by the framework. This is required to enforce uniqueness of `ranges::view::chunk` which stores iterator pair to
556+ // a generic container.
557+ //
558+ // ??? "More about byte-level hashing"
559+ //
560+ // The goal is to avoid incorrect cache hits (reusing a view for the “wrong” element or group), especially for
561+ // tricky range types like `ranges::views::chunk` that store iterator pairs and are sensitive to container
562+ // mutations.
563+ //
564+ // The subrange object (e.g., from `ranges::views::chunk`) contains iterators pointing to the original
565+ // container. If the container is modified (elements added/removed), these iterators may become invalid or
566+ // shifted. Since cached view captures the non-owning subrange object by value, iterating over it later may lead
567+ // to unexpected results.
568+ //
569+ // The raw byte-level hash helps to identify underlying iterator changes that may not be reflected in
570+ // user-provided keys, preventing reuse of the stale cached views.
571+ //
572+ // The hash enforced by the framework can be customized by specializing `aui::for_each_ui::defaultKey<T>`.
573+ //
479574
480575 EXPECT_CALL (mTestObserver , onViewCreated (" Group 0" _as));
481576 EXPECT_CALL (mTestObserver , onViewCreated (" 1" _as));
@@ -502,7 +597,14 @@ TEST_F(UIDeclarativeForTest, IntGroupingDynamic1) {
502597 /* Also, depending on used container's iterator implementation, other groups might be evaluated as well. In our
503598 * case, we are using AVector, whose iterator is an offset from beginning. Since the offset has changed due to
504599 * removal, the iterator is considered dirty. This might not be the case for containers whose items are stored in
505- * heap, i.e., `std::list`. */
600+ * heap, i.e., `std::list`.
601+ *
602+ * We need to be especially careful with this. Since we are using ranges::view::chunk in our example, it stores
603+ * iterator pair to std::vector. Iterators of std::vector MUST BE invalidated since we have changed the container.
604+ *
605+ * Given that, we expect "Group 10" to be reevaluated, despite user-provided setKeyFunction outcome would give equal
606+ * hashes both before and after removal.
607+ */
506608 EXPECT_CALL (mTestObserver , onViewCreated (" Group 10" _as));
507609 {
508610 state->ints .writeScope ()->removeAll (2 );
0 commit comments