blob: f8338dd709ac76e4c2c0cba9d258ba1dea5e1f8b [file]
#include "rive/animation/state_machine_input_instance.hpp"
#include "rive/animation/state_machine_instance.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/constraints/scrolling/scroll_constraint.hpp"
#include "rive/layout/layout_component_style.hpp"
#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"
#include "rive_testing.hpp"
#include <catch.hpp>
#include <cstdio>
TEST_CASE("Stateful Component ViewModelInstance", "[silver]")
{
rive::SerializingFactory silver;
auto file =
ReadRiveFile("assets/component_stateful_vm_instance.riv", &silver);
auto artboard = file->artboardNamed("ParentArtboard");
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);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto heightProperty = vmi->propertyValue("h");
REQUIRE(heightProperty != nullptr);
REQUIRE(heightProperty->is<rive::ViewModelInstanceNumber>());
heightProperty->as<rive::ViewModelInstanceNumber>()->propertyValue(200);
int frames = 30;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
heightProperty->as<rive::ViewModelInstanceNumber>()->propertyValue(50);
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("component_stateful_vm_instance"));
}
TEST_CASE("Stateful Component ViewModelInstance multi", "[silver]")
{
rive::SerializingFactory silver;
auto file =
ReadRiveFile("assets/component_stateful_vm_instance_2.riv", &silver);
auto artboard = file->artboardNamed("Artboard");
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);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
auto labelProperty = vmi->propertyValue("label");
REQUIRE(labelProperty != nullptr);
REQUIRE(labelProperty->is<rive::ViewModelInstanceString>());
int frames = 30;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
labelProperty->as<rive::ViewModelInstanceString>()->propertyValue(
"Override");
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("component_stateful_vm_instance_2"));
}
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"));
}
/*
// Disable this test for now. Initially we decided not to create stateful
// VM instances for each list item to reduce overhead. This may change in
// the future and we can revisit this test at that point.
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"));
}
// Regression test for the stateful-vs-bound VMI lifecycle on a NestedArtboard:
// 1. With no source set, the stateful child VMI is the active binding.
// 2. Binding the source to an artboard whose VM matches the stateful child
// borrows the stateful child (active VMI == stateful child pointer).
// 3. Binding the source to an artboard with a different VM creates a new
// owned bound VMI; the stateful child is preserved (not destroyed).
// 4. Switching back to the matching artboard reuses the same stateful child
// pointer.
// Visual variety in the silver is driven through `labelInput` on the parent
// VMI — the supported way for users to change inner state — rather than by
// mutating the stateful child's properties directly.
// This locks in the contract of the m_activeViewModelInstance refactor.
TEST_CASE("Stateful nested artboard borrows stateful child on matching VM and "
"preserves it across different-VM swap",
"[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_source_switch.riv", &silver);
auto artboard = file->artboardNamed("ParentArtboard");
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());
// Resolve the parent VMI's artboard-typed property that drives the
// nested artboard's source.
auto sourceProp = vmi->propertyValue("sourceArtboard");
REQUIRE(sourceProp != nullptr);
REQUIRE(sourceProp->is<rive::ViewModelInstanceArtboard>());
auto vmiSourceArtboard = sourceProp->as<rive::ViewModelInstanceArtboard>();
// Resolve `labelInput` on the parent VMI. Bound (via input binding) to the
// label property of whichever artboard is currently active inside the
// stateful nested artboard. Driving the inner label through this property
// is the supported user flow; this test does NOT mutate the stateful
// child's properties directly.
auto labelInputProp = vmi->propertyValue("labelInput");
REQUIRE(labelInputProp != nullptr);
REQUIRE(labelInputProp->is<rive::ViewModelInstanceString>());
auto labelInput = labelInputProp->as<rive::ViewModelInstanceString>();
auto matchingSource = file->bindableArtboardNamed("MatchingArtboardA");
auto differentSource = file->bindableArtboardNamed("DifferentArtboardB");
REQUIRE(matchingSource != nullptr);
REQUIRE(differentSource != nullptr);
// The stateful nested artboard is the only NestedArtboard with
// isStateful() true in this asset.
rive::NestedArtboard* statefulNested = nullptr;
for (auto na : artboard->nestedArtboards())
{
if (na->isStateful())
{
statefulNested = na;
break;
}
}
REQUIRE(statefulNested != nullptr);
// Walk children() for the stateful child VMI (mirrors what
// NestedArtboard::findStatefulChildVmi does internally).
auto findStatefulChild =
[](rive::NestedArtboard* na) -> rive::ViewModelInstance* {
for (auto child : na->children())
{
if (child->is<rive::ViewModelInstance>())
{
return child->as<rive::ViewModelInstance>();
}
}
return nullptr;
};
// The VMI currently bound to the nested artboard's inner instance —
// i.e., what NestedArtboard's active VMI was passed through to the
// inner artboard's data context.
auto currentlyBoundVmi =
[](rive::NestedArtboard* na) -> rive::ViewModelInstance* {
auto* inst = na->artboardInstance();
if (inst == nullptr)
{
return nullptr;
}
auto ctx = inst->dataContext();
if (ctx == nullptr)
{
return nullptr;
}
return ctx->viewModelInstance().get();
};
auto runFrames = [&](int n) {
for (int i = 0; i < n; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
};
// === Step 1: initial state, no source bound from the parent VMI yet ===
// The stateful child VMI exists and is the active binding (borrowed).
auto* statefulChild = findStatefulChild(statefulNested);
REQUIRE(statefulChild != nullptr);
const auto initialStatefulVmId = statefulChild->viewModelId();
runFrames(5);
// === Step 2: bind source = MatchingArtboardA (same VM) ===
// We expect the active VMI to remain the stateful child (borrowed),
// not a freshly-created bound VMI.
vmiSourceArtboard->asset(matchingSource);
runFrames(5);
auto* boundAfterMatching = currentlyBoundVmi(statefulNested);
REQUIRE(boundAfterMatching != nullptr);
CHECK(boundAfterMatching == statefulChild);
// Drive the inner label via the supported input-binding path. The
// stateful child receives this through its `label` input.
labelInput->propertyValue("Matching A");
runFrames(10);
// === Step 3: bind source = DifferentArtboardB (different VM) ===
// We expect a NEW owned bound VMI to be created. The stateful child
// must NOT be destroyed by the switch and must NOT be the active VMI.
vmiSourceArtboard->asset(differentSource);
runFrames(5);
auto* boundAfterDifferent = currentlyBoundVmi(statefulNested);
REQUIRE(boundAfterDifferent != nullptr);
CHECK(boundAfterDifferent != statefulChild);
CHECK(boundAfterDifferent->viewModelId() != statefulChild->viewModelId());
// The stateful child is still present in children() (preserved by
// the parent Artboard's m_Objects ownership).
CHECK(findStatefulChild(statefulNested) == statefulChild);
CHECK(statefulChild->viewModelId() == initialStatefulVmId);
// Drive the inner label again — now it should flow into the
// dynamically-created bound VMI for DifferentArtboardB.
labelInput->propertyValue("Different B");
runFrames(10);
// === Step 4: bind source back to MatchingArtboardA ===
// The active VMI must be the stateful child again (pointer identity).
vmiSourceArtboard->asset(matchingSource);
runFrames(5);
auto* boundAfterSwitchBack = currentlyBoundVmi(statefulNested);
REQUIRE(boundAfterSwitchBack != nullptr);
CHECK(boundAfterSwitchBack == statefulChild);
// One more input-driven label change to confirm the input keeps
// flowing into the reused stateful child after the round trip.
labelInput->propertyValue("Matching A Again");
runFrames(10);
CHECK(silver.matches("stateful_source_switch"));
}
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"));
}
TEST_CASE("Stateful Component Keyed Triggers", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/stateful_keyed_trigger.riv", &silver);
auto artboard = file->artboardNamed("Artboard");
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);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
int frames = 60;
for (int i = 0; i < frames; i++)
{
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
}
CHECK(silver.matches("stateful_keyed_trigger"));
}