chore: Add more Stateful Component tests (#12438) 13be041786
Adds several more real world tests for Stateful Components. Tests also uncovered 2 bugs which are fixed in this PR.
- Fixes cross file copy/pasting stateful artboards to make sure any VM instances that are copied are correctly reparented when pasted.
- We missed the VM artboard property bound to NestedArtboard's artboard case. Fixed, so now we always setup/cleanup new stateful VM instances when artboards are swapped via databinding.

Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head
index 14029b4..90f2b67 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-ee268b5467f8f4bf67ad634d31a5b8e3e9ff6c71
+13be041786e7811cffc3d5a7a474357619625536
diff --git a/src/nested_artboard.cpp b/src/nested_artboard.cpp
index 707f7a5..42a0f94 100644
--- a/src/nested_artboard.cpp
+++ b/src/nested_artboard.cpp
@@ -139,6 +139,7 @@
             m_referencedArtboard = nullptr;
         }
         m_Instance = nullptr;
+        m_statefulViewModelInstance = nullptr;
         return;
     }
 
@@ -159,6 +160,33 @@
                 nestedStateMachine)); // take ownership
         }
         referencedArtboard(artboardInstance.release());
+
+        if (artboard->isStateful())
+        {
+            const bool cacheStale =
+                m_statefulViewModelInstance == nullptr ||
+                m_statefulViewModelInstance->viewModelId() !=
+                    artboard->viewModelId();
+            if (cacheStale)
+            {
+                auto vm = m_file != nullptr
+                              ? m_file->viewModel(artboard->viewModelId())
+                              : nullptr;
+                m_statefulViewModelInstance =
+                    vm != nullptr ? m_file->createDefaultViewModelInstance(vm)
+                                  : nullptr;
+                if (m_statefulViewModelInstance != nullptr)
+                {
+                    m_file->completeViewModelProperties(
+                        m_statefulViewModelInstance.get());
+                }
+            }
+        }
+        else
+        {
+            m_statefulViewModelInstance = nullptr;
+        }
+
         if (viewModelInstanceArtboard->boundViewModelInstance())
         {
             bindViewModelInstance(
diff --git a/tests/unit_tests/assets/stateful_artboard_swap.riv b/tests/unit_tests/assets/stateful_artboard_swap.riv
new file mode 100644
index 0000000..124633e
--- /dev/null
+++ b/tests/unit_tests/assets/stateful_artboard_swap.riv
Binary files differ
diff --git a/tests/unit_tests/assets/stateful_list_props.riv b/tests/unit_tests/assets/stateful_list_props.riv
new file mode 100644
index 0000000..4a2fc29
--- /dev/null
+++ b/tests/unit_tests/assets/stateful_list_props.riv
Binary files differ
diff --git a/tests/unit_tests/assets/stateful_multi_property.riv b/tests/unit_tests/assets/stateful_multi_property.riv
new file mode 100644
index 0000000..5e4e74b
--- /dev/null
+++ b/tests/unit_tests/assets/stateful_multi_property.riv
Binary files differ
diff --git a/tests/unit_tests/assets/stateful_nested.riv b/tests/unit_tests/assets/stateful_nested.riv
new file mode 100644
index 0000000..5ccd4b2
--- /dev/null
+++ b/tests/unit_tests/assets/stateful_nested.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/component_test.cpp b/tests/unit_tests/runtime/component_test.cpp
index 3268c4a..2e0b440 100644
--- a/tests/unit_tests/runtime/component_test.cpp
+++ b/tests/unit_tests/runtime/component_test.cpp
@@ -6,9 +6,18 @@
 #include "rive/math/transform_components.hpp"
 #include "rive/shapes/rectangle.hpp"
 #include "rive/text/text.hpp"
+#include "rive/viewmodel/viewmodel_instance_artboard.hpp"
+#include "rive/viewmodel/viewmodel_instance_boolean.hpp"
+#include "rive/viewmodel/viewmodel_instance_color.hpp"
+#include "rive/viewmodel/viewmodel_instance_enum.hpp"
+#include "rive/viewmodel/viewmodel_instance_list.hpp"
+#include "rive/viewmodel/viewmodel_instance_list_item.hpp"
 #include "rive/viewmodel/viewmodel_instance_number.hpp"
 #include "rive/viewmodel/viewmodel_instance_string.hpp"
 #include "rive/viewmodel/viewmodel_instance_symbol_list_index.hpp"
+#include "rive/viewmodel/viewmodel_instance_trigger.hpp"
+#include "rive/assets/file_asset.hpp"
+#include "rive/nested_artboard.hpp"
 #include "utils/no_op_factory.hpp"
 #include "utils/serializing_factory.hpp"
 #include "rive_file_reader.hpp"
@@ -106,4 +115,632 @@
     }
 
     CHECK(silver.matches("component_stateful_vm_instance_2"));
-}
\ No newline at end of file
+}
+
+TEST_CASE("Stateful Component multi-property independence", "[silver]")
+{
+    rive::SerializingFactory silver;
+    auto file = ReadRiveFile("assets/stateful_multi_property.riv", &silver);
+
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    int viewModelId = artboard.get()->viewModelId();
+
+    auto vmi = viewModelId == -1
+                   ? file->createViewModelInstance(artboard.get())
+                   : file->createViewModelInstance(viewModelId, 0);
+    REQUIRE(vmi != nullptr);
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.1f);
+
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    // Sanity: all parent props exist with expected types.
+    auto btn1Count = vmi->propertyValue("btn1Count");
+    auto btn1Tint = vmi->propertyValue("btn1Tint");
+    auto btn1Label = vmi->propertyValue("btn1Label");
+    auto btn1Clip = vmi->propertyValue("btn1Clip");
+    auto btn1Display = vmi->propertyValue("btn1Display");
+    auto btn2Count = vmi->propertyValue("btn2Count");
+    auto btn2Tint = vmi->propertyValue("btn2Tint");
+    auto btn2Label = vmi->propertyValue("btn2Label");
+    auto btn2Clip = vmi->propertyValue("btn2Clip");
+    auto btn2Display = vmi->propertyValue("btn2Display");
+    REQUIRE(btn1Count != nullptr);
+    REQUIRE(btn1Count->is<rive::ViewModelInstanceNumber>());
+    REQUIRE(btn1Tint != nullptr);
+    REQUIRE(btn1Tint->is<rive::ViewModelInstanceColor>());
+    REQUIRE(btn1Label != nullptr);
+    REQUIRE(btn1Label->is<rive::ViewModelInstanceString>());
+    REQUIRE(btn1Clip != nullptr);
+    REQUIRE(btn1Clip->is<rive::ViewModelInstanceBoolean>());
+    REQUIRE(btn1Display != nullptr);
+    REQUIRE(btn1Display->is<rive::ViewModelInstanceEnum>());
+    REQUIRE(btn2Count != nullptr);
+    REQUIRE(btn2Count->is<rive::ViewModelInstanceNumber>());
+    REQUIRE(btn2Tint != nullptr);
+    REQUIRE(btn2Tint->is<rive::ViewModelInstanceColor>());
+    REQUIRE(btn2Label != nullptr);
+    REQUIRE(btn2Label->is<rive::ViewModelInstanceString>());
+    REQUIRE(btn2Clip != nullptr);
+    REQUIRE(btn2Clip->is<rive::ViewModelInstanceBoolean>());
+    REQUIRE(btn2Display != nullptr);
+    REQUIRE(btn2Display->is<rive::ViewModelInstanceEnum>());
+
+    auto runFrames = [&](int frames) {
+        for (int i = 0; i < frames; i++)
+        {
+            silver.addFrame();
+            stateMachine->advanceAndApply(0.016f);
+            artboard->draw(renderer.get());
+        }
+    };
+
+    // Drive btn1 properties one by one — btn2 should remain unchanged
+    // throughout. Each segment isolates a single property type so the
+    // snapshot makes the binding visible per-type.
+    btn1Count->as<rive::ViewModelInstanceNumber>()->propertyValue(180);
+    runFrames(5);
+
+    btn1Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFFFF3344);
+    runFrames(5);
+
+    btn1Label->as<rive::ViewModelInstanceString>()->propertyValue("One");
+    runFrames(5);
+
+    btn1Clip->as<rive::ViewModelInstanceBoolean>()->propertyValue(true);
+    runFrames(5);
+
+    btn1Display->as<rive::ViewModelInstanceEnum>()->value(1);
+    runFrames(5);
+
+    // Now drive btn2 — btn1 should keep its modified values from above.
+    btn2Count->as<rive::ViewModelInstanceNumber>()->propertyValue(60);
+    runFrames(5);
+
+    btn2Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFF33AAFF);
+    runFrames(5);
+
+    btn2Label->as<rive::ViewModelInstanceString>()->propertyValue("Two");
+    runFrames(5);
+
+    btn2Clip->as<rive::ViewModelInstanceBoolean>()->propertyValue(true);
+    runFrames(5);
+
+    btn2Display->as<rive::ViewModelInstanceEnum>()->value(1);
+    runFrames(5);
+
+    CHECK(silver.matches("stateful_multi_property"));
+}
+
+TEST_CASE("Stateful Component nested in stateful", "[silver]")
+{
+    rive::SerializingFactory silver;
+    auto file = ReadRiveFile("assets/stateful_nested.riv", &silver);
+
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    int viewModelId = artboard.get()->viewModelId();
+
+    auto vmi = viewModelId == -1
+                   ? file->createViewModelInstance(artboard.get())
+                   : file->createViewModelInstance(viewModelId, 0);
+    REQUIRE(vmi != nullptr);
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.1f);
+
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    auto btn1Label = vmi->propertyValue("btn1Label");
+    auto btn2Label = vmi->propertyValue("btn2Label");
+    auto btn1Tint = vmi->propertyValue("btn1Tint");
+    auto btn2Tint = vmi->propertyValue("btn2Tint");
+    REQUIRE(btn1Label != nullptr);
+    REQUIRE(btn1Label->is<rive::ViewModelInstanceString>());
+    REQUIRE(btn2Label != nullptr);
+    REQUIRE(btn2Label->is<rive::ViewModelInstanceString>());
+    REQUIRE(btn1Tint != nullptr);
+    REQUIRE(btn1Tint->is<rive::ViewModelInstanceColor>());
+    REQUIRE(btn2Tint != nullptr);
+    REQUIRE(btn2Tint->is<rive::ViewModelInstanceColor>());
+
+    auto runFrames = [&](int frames) {
+        for (int i = 0; i < frames; i++)
+        {
+            silver.addFrame();
+            stateMachine->advanceAndApply(0.016f);
+            artboard->draw(renderer.get());
+        }
+    };
+
+    // Drive each property in isolation. Each segment exercises a single
+    // input on the outer Main VM that is wired down through MultiButton's
+    // stateful boundary into one of its two inner Button instances.
+    btn1Label->as<rive::ViewModelInstanceString>()->propertyValue("One");
+    runFrames(5);
+
+    btn1Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFFFF3344);
+    runFrames(5);
+
+    btn2Label->as<rive::ViewModelInstanceString>()->propertyValue("Two");
+    runFrames(5);
+
+    btn2Tint->as<rive::ViewModelInstanceColor>()->propertyValue(0xFF33AAFF);
+    runFrames(5);
+
+    CHECK(silver.matches("stateful_nested"));
+}
+
+TEST_CASE("Stateful Component list with input/output bridge binds", "[silver]")
+{
+    rive::SerializingFactory silver;
+    auto file = ReadRiveFile("assets/stateful_list_props.riv", &silver);
+
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    int viewModelId = artboard.get()->viewModelId();
+
+    auto vmi = viewModelId == -1
+                   ? file->createViewModelInstance(artboard.get())
+                   : file->createViewModelInstance(viewModelId, 0);
+    REQUIRE(vmi != nullptr);
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.0f);
+
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    auto buttonsProp = vmi->propertyValue("buttons");
+    REQUIRE(buttonsProp != nullptr);
+    REQUIRE(buttonsProp->is<rive::ViewModelInstanceList>());
+    auto buttonsList = buttonsProp->as<rive::ViewModelInstanceList>();
+    REQUIRE(buttonsList != nullptr);
+
+    // Helper: build a fresh ButtonVM instance with the given inputs.
+    auto makeButton =
+        [&](float count, uint32_t tint, const std::string& label, bool clip) {
+            auto inst = file->createViewModelInstance("ButtonVM");
+            REQUIRE(inst != nullptr);
+            auto pCount = inst->propertyValue("count");
+            auto pTint = inst->propertyValue("tint");
+            auto pLabel = inst->propertyValue("label");
+            auto pClip = inst->propertyValue("clipped");
+            REQUIRE(pCount != nullptr);
+            REQUIRE(pTint != nullptr);
+            REQUIRE(pLabel != nullptr);
+            REQUIRE(pClip != nullptr);
+            REQUIRE(pCount->is<rive::ViewModelInstanceNumber>());
+            REQUIRE(pTint->is<rive::ViewModelInstanceColor>());
+            REQUIRE(pLabel->is<rive::ViewModelInstanceString>());
+            REQUIRE(pClip->is<rive::ViewModelInstanceBoolean>());
+            pCount->as<rive::ViewModelInstanceNumber>()->propertyValue(count);
+            pTint->as<rive::ViewModelInstanceColor>()->propertyValue(tint);
+            pLabel->as<rive::ViewModelInstanceString>()->propertyValue(label);
+            pClip->as<rive::ViewModelInstanceBoolean>()->propertyValue(clip);
+            return inst;
+        };
+
+    auto addItem = [&](rive::rcp<rive::ViewModelInstance> inst) {
+        auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
+        item->viewModelInstance(inst);
+        buttonsList->addItem(item);
+    };
+
+    // Three buttons with distinct inputs. We keep handles to the
+    // *original* (user-supplied) ButtonVM instances — these are the ones
+    // whose `click` trigger is written back via the output bridge bind
+    // (clone -> original) when the corresponding rendered button is
+    // pressed.
+    auto button0 = makeButton(20, 0xFFFF3344, "Alpha", false);
+    auto button1 = makeButton(40, 0xFF33AAFF, "Beta", false);
+    auto button2 = makeButton(60, 0xFF44CC55, "Gamma", false);
+    addItem(button0);
+    addItem(button1);
+    addItem(button2);
+
+    auto runFrames = [&](int frames) {
+        for (int i = 0; i < frames; i++)
+        {
+            silver.addFrame();
+            stateMachine->advanceAndApply(0.016f);
+            artboard->draw(renderer.get());
+        }
+    };
+
+    // Settle the list with all three items visible.
+    runFrames(5);
+
+    auto getClickedBool = [&](rive::rcp<rive::ViewModelInstance> btn) {
+        auto p = btn->propertyValue("clicked");
+        REQUIRE(p != nullptr);
+        REQUIRE(p->is<rive::ViewModelInstanceBoolean>());
+        return p->as<rive::ViewModelInstanceBoolean>();
+    };
+    auto clicked0 = getClickedBool(button0);
+    auto clicked1 = getClickedBool(button1);
+    auto clicked2 = getClickedBool(button2);
+
+    // To observe trigger fires on the original VMs we attach a delegate.
+    // Reading `propertyValue()` post-frame doesn't work for triggers
+    // because `ViewModelInstanceTrigger::advanced()` resets the value
+    // back to 0 every frame — that's their normal "fired this frame"
+    // semantic. The delegate's `valueChanged` is invoked in
+    // `propertyValueChanged()`, before the reset, so it captures real
+    // fires.
+    struct FireCounter : public rive::ViewModelInstanceValueDelegate
+    {
+        int count = 0;
+        void valueChanged() override { count++; }
+    };
+    FireCounter fire0, fire1, fire2;
+    auto getClickTrigger = [&](rive::rcp<rive::ViewModelInstance> btn) {
+        auto p = btn->propertyValue("click");
+        REQUIRE(p != nullptr);
+        REQUIRE(p->is<rive::ViewModelInstanceTrigger>());
+        return p->as<rive::ViewModelInstanceTrigger>();
+    };
+    auto trigger0 = getClickTrigger(button0);
+    auto trigger1 = getClickTrigger(button1);
+    auto trigger2 = getClickTrigger(button2);
+    trigger0->addDelegate(&fire0);
+    trigger1->addDelegate(&fire1);
+    trigger2->addDelegate(&fire2);
+
+    // --- Input bridge: modify inputs on the originals; the cloned VMIs
+    // inside the list should pick up the new values. ---
+    button1->propertyValue("label")
+        ->as<rive::ViewModelInstanceString>()
+        ->propertyValue("Beta!");
+    runFrames(5);
+
+    button2->propertyValue("tint")
+        ->as<rive::ViewModelInstanceColor>()
+        ->propertyValue(0xFFFFCC00);
+    runFrames(5);
+
+    button0->propertyValue("count")
+        ->as<rive::ViewModelInstanceNumber>()
+        ->propertyValue(80);
+    runFrames(5);
+
+    // --- Output bridge: clicks on the rendered list items must
+    // propagate the `clicked` boolean back to the originals. ---
+    // Layout: 10px padding, ~35px button height, 10px gap, vertical.
+    // Button 0 center y = 10 + 35/2 = 27.5
+    // Button 1 center y = 10 + 35 + 10 + 35/2 = 72.5
+    // Button 2 center y = 10 + 2*(35+10) + 35/2 = 117.5
+
+    // Sanity: nothing has been clicked yet.
+    CHECK(clicked0->propertyValue() == false);
+    CHECK(clicked1->propertyValue() == false);
+    CHECK(clicked2->propertyValue() == false);
+    CHECK(fire0.count == 0);
+    CHECK(fire1.count == 0);
+    CHECK(fire2.count == 0);
+
+    // Click button 0 — pointerDown + pointerUp completes the click, then
+    // we advance once and read `clicked` and the trigger fire count.
+    stateMachine->pointerDown(rive::Vec2D(50.0f, 27.0f));
+    stateMachine->pointerUp(rive::Vec2D(50.0f, 27.0f));
+    stateMachine->advanceAndApply(0.016f);
+    CHECK(clicked0->propertyValue() == true);
+    CHECK(clicked1->propertyValue() == false);
+    CHECK(clicked2->propertyValue() == false);
+    int fire0After1 = fire0.count;
+    int fire1After1 = fire1.count;
+    int fire2After1 = fire2.count;
+    CHECK(fire0After1 > 0);
+    CHECK(fire1After1 == 0);
+    CHECK(fire2After1 == 0);
+    runFrames(3);
+
+    // Click button 1.
+    stateMachine->pointerDown(rive::Vec2D(50.0f, 73.0f));
+    stateMachine->pointerUp(rive::Vec2D(50.0f, 73.0f));
+    stateMachine->advanceAndApply(0.016f);
+    CHECK(clicked1->propertyValue() == true);
+    CHECK(fire0.count == fire0After1);
+    CHECK(fire1.count > fire1After1);
+    CHECK(fire2.count == fire2After1);
+    int fire0After2 = fire0.count;
+    int fire1After2 = fire1.count;
+    int fire2After2 = fire2.count;
+    runFrames(3);
+
+    // Click button 2.
+    stateMachine->pointerDown(rive::Vec2D(50.0f, 118.0f));
+    stateMachine->pointerUp(rive::Vec2D(50.0f, 118.0f));
+    stateMachine->advanceAndApply(0.016f);
+    CHECK(clicked2->propertyValue() == true);
+    CHECK(fire0.count == fire0After2);
+    CHECK(fire1.count == fire1After2);
+    CHECK(fire2.count > fire2After2);
+    runFrames(3);
+
+    CHECK(silver.matches("stateful_list_props"));
+}
+
+TEST_CASE("Stateful Component dynamic artboard swap via VMI artboard "
+          "property",
+          "[silver]")
+{
+    rive::SerializingFactory silver;
+    auto file = ReadRiveFile("assets/stateful_artboard_swap.riv", &silver);
+
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    int viewModelId = artboard.get()->viewModelId();
+
+    auto vmi = viewModelId == -1
+                   ? file->createViewModelInstance(artboard.get())
+                   : file->createViewModelInstance(viewModelId, 0);
+    REQUIRE(vmi != nullptr);
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.0f);
+
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    auto buttonArtboardProp = vmi->propertyValue("buttonArtboard");
+    REQUIRE(buttonArtboardProp != nullptr);
+    REQUIRE(buttonArtboardProp->is<rive::ViewModelInstanceArtboard>());
+    auto vmiArtboard =
+        buttonArtboardProp->as<rive::ViewModelInstanceArtboard>();
+
+    auto buttonSource = file->bindableArtboardNamed("Button");
+    auto strokedButtonSource = file->bindableArtboardNamed("StrokedButton");
+    REQUIRE(buttonSource != nullptr);
+    REQUIRE(strokedButtonSource != nullptr);
+
+    auto runFrames = [&](int frames) {
+        for (int i = 0; i < frames; i++)
+        {
+            silver.addFrame();
+            stateMachine->advanceAndApply(0.016f);
+            artboard->draw(renderer.get());
+        }
+    };
+
+    // Helper: navigate to the swap-target NestedArtboard's currently-bound
+    // stateful VMI so we can verify it was created fresh after each swap.
+    auto findSwappedNestedArtboard = [&]() -> rive::NestedArtboard* {
+        for (auto na : artboard->nestedArtboards())
+        {
+            // The swap-target NA is the one whose source is driven by
+            // `buttonArtboard`; the easiest way to recognize it is that
+            // its current source artboard matches one of the variants.
+            auto* src = na->sourceArtboard();
+            if (src != nullptr &&
+                (src->name() == "Button" || src->name() == "StrokedButton"))
+            {
+                return na;
+            }
+        }
+        return nullptr;
+    };
+
+    // Initial: buttonArtboard is null, so nothing is rendered in the
+    // swap target. Capture the empty baseline.
+    runFrames(2);
+    REQUIRE(findSwappedNestedArtboard() == nullptr);
+
+    // Swap to Button.
+    vmiArtboard->asset(buttonSource);
+    runFrames(5);
+
+    auto naAfterButton = findSwappedNestedArtboard();
+    REQUIRE(naAfterButton != nullptr);
+    REQUIRE(naAfterButton->sourceArtboard() != nullptr);
+    CHECK(naAfterButton->sourceArtboard()->name() == "Button");
+
+    // Swap to StrokedButton — exercises updateArtboard's dispose+rebuild
+    // path. The stateful artboard should change types entirely; the
+    // previous Button stateful VMI should be torn down.
+    vmiArtboard->asset(strokedButtonSource);
+    runFrames(5);
+
+    auto naAfterStroked = findSwappedNestedArtboard();
+    REQUIRE(naAfterStroked != nullptr);
+    REQUIRE(naAfterStroked->sourceArtboard() != nullptr);
+    CHECK(naAfterStroked->sourceArtboard()->name() == "StrokedButton");
+
+    // Sanity: the StrokedButton instance has its VM-specific
+    // `strokeWidth` property visible, but Button's VM does not.
+    auto strokedAbInst = naAfterStroked->artboardInstance();
+    REQUIRE(strokedAbInst != nullptr);
+    auto strokedCtx = strokedAbInst->dataContext();
+    REQUIRE(strokedCtx != nullptr);
+    auto strokedVmi = strokedCtx->viewModelInstance();
+    REQUIRE(strokedVmi != nullptr);
+    auto strokeWidthProp = strokedVmi->propertyValue("strokeWidth");
+    REQUIRE(strokeWidthProp != nullptr);
+    REQUIRE(strokeWidthProp->is<rive::ViewModelInstanceNumber>());
+    // Drive strokeWidth so the silver actually shows the stroke and we
+    // can visually verify the StrokedButton variant rendered.
+    strokeWidthProp->as<rive::ViewModelInstanceNumber>()->propertyValue(8);
+    runFrames(5);
+
+    // Swap back to Button — the previous StrokedButton stateful VMI
+    // (with strokeWidth) must be cleaned up; we should land back on a
+    // ButtonVM-shaped instance.
+    vmiArtboard->asset(buttonSource);
+    runFrames(5);
+
+    auto naAfterButton2 = findSwappedNestedArtboard();
+    REQUIRE(naAfterButton2 != nullptr);
+    REQUIRE(naAfterButton2->sourceArtboard() != nullptr);
+    CHECK(naAfterButton2->sourceArtboard()->name() == "Button");
+
+    auto buttonAbInst = naAfterButton2->artboardInstance();
+    REQUIRE(buttonAbInst != nullptr);
+    auto buttonCtx = buttonAbInst->dataContext();
+    REQUIRE(buttonCtx != nullptr);
+    auto buttonVmi = buttonCtx->viewModelInstance();
+    REQUIRE(buttonVmi != nullptr);
+    // Button's VM has count/tint/label/clipped/clicked/click but
+    // does NOT have strokeWidth.
+    CHECK(buttonVmi->propertyValue("count") != nullptr);
+    CHECK(buttonVmi->propertyValue("strokeWidth") == nullptr);
+
+    // Swap back to null — the swap target should disappear again.
+    // asset(nullptr) calls propertyValue(-1) internally, which together
+    // satisfy the cleared-state condition in NestedArtboard::updateArtboard.
+    vmiArtboard->asset(nullptr);
+    runFrames(5);
+    CHECK(findSwappedNestedArtboard() == nullptr);
+
+    // Swap back to Button after the null-clear. Verifies that the
+    // cleared-state path actually drops the cached stateful VMI — if it
+    // didn't, this swap would land on a VMI that was held over from the
+    // previous variant's lifetime instead of a freshly created one. We
+    // can't easily assert ptr-inequality without holding refs, so we
+    // settle for asserting the new VMI is valid and shaped like
+    // ButtonVM.
+    vmiArtboard->asset(buttonSource);
+    runFrames(5);
+    auto naAfterClear = findSwappedNestedArtboard();
+    REQUIRE(naAfterClear != nullptr);
+    REQUIRE(naAfterClear->sourceArtboard() != nullptr);
+    CHECK(naAfterClear->sourceArtboard()->name() == "Button");
+    auto buttonAbInst3 = naAfterClear->artboardInstance();
+    REQUIRE(buttonAbInst3 != nullptr);
+    auto buttonCtx3 = buttonAbInst3->dataContext();
+    REQUIRE(buttonCtx3 != nullptr);
+    auto buttonVmi3 = buttonCtx3->viewModelInstance();
+    REQUIRE(buttonVmi3 != nullptr);
+    CHECK(buttonVmi3->propertyValue("count") != nullptr);
+    CHECK(buttonVmi3->propertyValue("strokeWidth") == nullptr);
+
+    CHECK(silver.matches("stateful_artboard_swap"));
+}
+
+TEST_CASE("Stateful Component list bridge binds clean up on item remove",
+          "[silver]")
+{
+    // Reuses stateful_list_props.riv. Verifies that adding and then
+    // removing list items doesn't leak bridge binds or crash, and that
+    // surviving items still propagate clicks correctly.
+    rive::SerializingFactory silver;
+    auto file = ReadRiveFile("assets/stateful_list_props.riv", &silver);
+
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    int viewModelId = artboard.get()->viewModelId();
+
+    auto vmi = viewModelId == -1
+                   ? file->createViewModelInstance(artboard.get())
+                   : file->createViewModelInstance(viewModelId, 0);
+    REQUIRE(vmi != nullptr);
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.0f);
+
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    auto buttonsList =
+        vmi->propertyValue("buttons")->as<rive::ViewModelInstanceList>();
+    REQUIRE(buttonsList != nullptr);
+
+    auto makeAndAdd = [&](const std::string& label, uint32_t tint) {
+        auto inst = file->createViewModelInstance("ButtonVM");
+        REQUIRE(inst != nullptr);
+        inst->propertyValue("label")
+            ->as<rive::ViewModelInstanceString>()
+            ->propertyValue(label);
+        inst->propertyValue("tint")
+            ->as<rive::ViewModelInstanceColor>()
+            ->propertyValue(tint);
+        auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
+        item->viewModelInstance(inst);
+        buttonsList->addItem(item);
+        return inst;
+    };
+
+    auto runFrames = [&](int frames) {
+        for (int i = 0; i < frames; i++)
+        {
+            silver.addFrame();
+            stateMachine->advanceAndApply(0.016f);
+            artboard->draw(renderer.get());
+        }
+    };
+
+    auto buttonA = makeAndAdd("Alpha", 0xFFFF3344);
+    auto buttonB = makeAndAdd("Beta", 0xFF33AAFF);
+    auto buttonC = makeAndAdd("Gamma", 0xFF44CC55);
+    runFrames(3);
+
+    // Remove the middle item — Beta. Bridge binds for Beta must be torn
+    // down; Alpha and Gamma must keep working.
+    REQUIRE(buttonsList->listItems().size() == 3);
+    buttonsList->removeItem(1);
+    runFrames(5);
+    REQUIRE(buttonsList->listItems().size() == 2);
+
+    // Click survivor at the position now occupied by what was Gamma
+    // (originally index 2, now index 1 → y ≈ 73).
+    auto clickedC =
+        buttonC->propertyValue("clicked")->as<rive::ViewModelInstanceBoolean>();
+    auto clickedB =
+        buttonB->propertyValue("clicked")->as<rive::ViewModelInstanceBoolean>();
+    CHECK(clickedC->propertyValue() == false);
+    stateMachine->pointerDown(rive::Vec2D(50.0f, 73.0f));
+    stateMachine->pointerUp(rive::Vec2D(50.0f, 73.0f));
+    stateMachine->advanceAndApply(0.016f);
+    CHECK(clickedC->propertyValue() == true);
+    // Removed item's clicked must not flip — its bridge bind is gone.
+    CHECK(clickedB->propertyValue() == false);
+    runFrames(3);
+
+    // Re-add Beta at the end.
+    {
+        auto item = rive::make_rcp<rive::ViewModelInstanceListItem>();
+        item->viewModelInstance(buttonB);
+        buttonsList->addItem(item);
+    }
+    runFrames(5);
+    REQUIRE(buttonsList->listItems().size() == 3);
+
+    // Beta now lives at index 2 → y ≈ 118. Click should bridge-bind to
+    // its `clicked` again.
+    stateMachine->pointerDown(rive::Vec2D(50.0f, 118.0f));
+    stateMachine->pointerUp(rive::Vec2D(50.0f, 118.0f));
+    stateMachine->advanceAndApply(0.016f);
+    CHECK(clickedB->propertyValue() == true);
+    runFrames(3);
+
+    // Remove all items — must not crash, no leftover binds.
+    while (buttonsList->listItems().size() > 0)
+    {
+        buttonsList->removeItem(0);
+    }
+    runFrames(5);
+    REQUIRE(buttonsList->listItems().size() == 0);
+
+    CHECK(silver.matches("stateful_list_props_lifecycle"));
+}
diff --git a/tests/unit_tests/silvers/stateful_artboard_swap.sriv b/tests/unit_tests/silvers/stateful_artboard_swap.sriv
new file mode 100644
index 0000000..4a54557
--- /dev/null
+++ b/tests/unit_tests/silvers/stateful_artboard_swap.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_list_props.sriv b/tests/unit_tests/silvers/stateful_list_props.sriv
new file mode 100644
index 0000000..32b177b
--- /dev/null
+++ b/tests/unit_tests/silvers/stateful_list_props.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv b/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv
new file mode 100644
index 0000000..4b1378a
--- /dev/null
+++ b/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_multi_property.sriv b/tests/unit_tests/silvers/stateful_multi_property.sriv
new file mode 100644
index 0000000..516740e
--- /dev/null
+++ b/tests/unit_tests/silvers/stateful_multi_property.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_nested.sriv b/tests/unit_tests/silvers/stateful_nested.sriv
new file mode 100644
index 0000000..999c042
--- /dev/null
+++ b/tests/unit_tests/silvers/stateful_nested.sriv
Binary files differ