fix(runtime): don't blur into deleted components when destroying an artboard on an adopted focus manager (#14504) ac3546564e
~Artboard already makes a pass over m_Objects before deleting anything, to find the view model objects. That pass (renamed gatherVmObjects → preDeletePass) now also calls FocusData::detachFocusable(), which clears the node's pointer back to its FocusData without touching the manager (which may be freed). Once deletion starts, the blur walk still runs and focus is still cleared on the manager, but no callback can reach this artboard's components.

Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head
index 700b2f5..dc1d34d 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-9323bb804febac324538d4b8d8fe11c5e7bdc4b2
+ac3546564e218186245ce3ca4ea4e3dde7300153
diff --git a/include/rive/focus_data.hpp b/include/rive/focus_data.hpp
index 7d481d9..5646e9e 100644
--- a/include/rive/focus_data.hpp
+++ b/include/rive/focus_data.hpp
@@ -23,6 +23,14 @@
 
     rcp<FocusNode> focusNode();
 
+    void detachFocusable()
+    {
+        if (m_focusNode != nullptr)
+        {
+            m_focusNode->clearFocusable();
+        }
+    }
+
     /// Register a listener to be notified of focus/blur events.
     void addFocusListener(FocusListener* listener);
 
diff --git a/src/artboard.cpp b/src/artboard.cpp
index 9f6b17f..d0222c2 100644
--- a/src/artboard.cpp
+++ b/src/artboard.cpp
@@ -172,11 +172,19 @@
     // First pass: identify ViewModelInstance and ViewModelInstanceValue
     // objects while memory is valid (before any deletions). Precompute which
     // VMIs should be released so we never dereference pointers after deletes.
-    auto gatherVmObjects = [&](Core* object) {
+    // Also detach FocusData from their nodes: with an adopted manager the
+    // focus tree above wasn't torn down, and deleting a focused node blurs the
+    // chain, which must not call into components already deleted.
+    auto preDeletePass = [&](Core* object) {
         if (object == nullptr || object == this)
         {
             return;
         }
+        if (object->is<FocusData>())
+        {
+            object->as<FocusData>()->detachFocusable();
+            return;
+        }
         if (object->is<ViewModelInstance>())
         {
             vmObjects.insert(object);
@@ -190,11 +198,11 @@
     };
     for (auto object : m_Objects)
     {
-        gatherVmObjects(object);
+        preDeletePass(object);
     }
     for (auto object : m_invalidObjects)
     {
-        gatherVmObjects(object);
+        preDeletePass(object);
     }
 
     auto isVmObject = [&](Core* object) -> bool {
diff --git a/tests/unit_tests/runtime/focus_test.cpp b/tests/unit_tests/runtime/focus_test.cpp
index a3508ea..5f9b907 100644
--- a/tests/unit_tests/runtime/focus_test.cpp
+++ b/tests/unit_tests/runtime/focus_test.cpp
@@ -5018,3 +5018,41 @@
 
     CHECK(silver.matches("gamepad_inputs_test-collapsing"));
 }
+
+TEST_CASE("Destroying an artboard on an adopted manager with focus held in a "
+          "nested artboard",
+          "[FocusManager]")
+{
+    // Editor shape: Dart owns the manager, so ~Artboard skips
+    // cleanupFocusTree and focus is still live when the m_Objects loop runs.
+    // Blur callbacks fired from that loop must not reach deleted components.
+    rive::FocusManager external;
+    auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    artboard->adoptFocusManager(&external);
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    REQUIRE(stateMachine->focusManager() == &external);
+
+    auto vmi = file->createDefaultViewModelInstance(artboard.get());
+    REQUIRE(vmi != nullptr);
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.016f);
+    stateMachine->advanceAndApply(0.016f);
+
+    CHECK(stateMachine->focusNext() == true);
+    CHECK(stateMachine->focusNext() == true);
+    CHECK(stateMachine->focusNext() == true);
+    auto* focusedArtboard = external.primaryFocusImmediateArtboard();
+    REQUIRE(focusedArtboard != nullptr);
+    REQUIRE(focusedArtboard->name() == "StaticNestWithFocusable");
+
+    // Same order as WrappedStateMachine: machine first, then artboard.
+    stateMachine = nullptr;
+    artboard = nullptr;
+
+    CHECK(external.primaryFocus() == nullptr);
+    CHECK(external.hasFocusableContent() == false);
+}