fix(runtime): keep pre-7.3 files on the legacy text sizing path (#13886) 9fe2b415a7
#13223 made a layout-controlled text's box always match its layout slot, and let overflow modes engage for auto-sized text inside layouts. The fix is right, but it changes how existing files render: wherever a hug layout's min/max made the slot disagree with the content, bounds move — and overflow properties that had always been inert suddenly take effect, so a text someone set to ellipsis years ago and saw do nothing now truncates.

Gate the new behavior on the file's riv version. Text::import stamps m_layoutSizesBox from the import stack (>= 7.3); below that, bounds fall back to content size and overflowAsFixed() collapses to its pre-#13223 form. Every call site already routed through that one predicate, so the legacy path is an exact revert rather than an approximation.

7.3 is the existing bump from #13400 (Aug 5) rather than a new one, and that dates well: it postdates #13223 (Jul 24), so every file stamped 7.3 genuinely has the new behavior. Only exports from the Jul 24 – Aug 5 window are misclassified as legacy.

Co-authored-by: Philip Chung <philterdesign@gmail.com>
diff --git a/.rive_head b/.rive_head
index 9e5f012..358d82e 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-636e181568efe063143289c67fda6cab49143f0c
+9fe2b415a70bb2d6460a9df2c2e4a6caf8589c30
diff --git a/include/rive/text/text.hpp b/include/rive/text/text.hpp
index 874bfae..62b2d5e 100644
--- a/include/rive/text/text.hpp
+++ b/include/rive/text/text.hpp
@@ -237,6 +237,8 @@
     AABB constraintBounds() const override { return localBounds(); }
     void originXChanged() override;
     void originYChanged() override;
+    StatusCode import(ImportStack& importStack) override;
+    Core* clone() const override;
 
     Vec2D measureLayout(float width,
                         LayoutMeasureMode widthMode,
@@ -254,11 +256,24 @@
     {
         return std::isnan(m_layoutHeight) ? height() : m_layoutHeight;
     }
-    // Overflow treats the box as fixed once a layout controls our size.
+    // Overflow treats the box as fixed once a layout sizes our box.
     bool overflowAsFixed() const
     {
         return effectiveSizing() == TextSizing::fixed ||
-               !std::isnan(m_layoutWidth);
+               !std::isnan(layoutBoxWidth());
+    }
+    // The size a controlling layout imposes on our *box*, or NAN when it
+    // imposes none. Not the same as effectiveWidth/Height above, which use
+    // m_layoutWidth/Height whenever a layout controls us: before 7.3 the
+    // layout sized the text but an auto-sized box still came from the
+    // content, so these are NAN there. See Text::import.
+    float layoutBoxWidth() const
+    {
+        return m_layoutSizesBox ? m_layoutWidth : NAN;
+    }
+    float layoutBoxHeight() const
+    {
+        return m_layoutSizesBox ? m_layoutHeight : NAN;
     }
     float computedWidth() override { return localBounds().width(); };
     float computedHeight() override { return localBounds().height(); };
@@ -379,6 +394,9 @@
     uint8_t m_layoutWidthScaleType = std::numeric_limits<uint8_t>::max();
     uint8_t m_layoutHeightScaleType = std::numeric_limits<uint8_t>::max();
     LayoutDirection m_layoutDirection = LayoutDirection::inherit;
+    // Whether a controlling layout sizes our box, not just our text. Stamped
+    // at import from the file version; true for anything built in memory.
+    bool m_layoutSizesBox = true;
     Vec2D measure(Vec2D maxSize);
 };
 } // namespace rive
diff --git a/src/text/text.cpp b/src/text/text.cpp
index 2d703a8..7ac912a 100644
--- a/src/text/text.cpp
+++ b/src/text/text.cpp
@@ -1,5 +1,6 @@
 #include "rive/text/text.hpp"
 #include "rive/layout/layout_participant.hpp"
+#include "rive/importers/import_stack.hpp"
 using namespace rive;
 #ifdef WITH_RIVE_TEXT
 #include "rive/text_engine.hpp"
@@ -602,19 +603,20 @@
     // Step 4: update bounds. A layout-controlled axis uses the layout's size
     // so the text box always matches the layout.
     const float paragraphSpace = fitParagraphSpacing();
+    const float boxWidth = layoutBoxWidth();
+    const float boxHeight = layoutBoxHeight();
     const float autoSizeMaxY =
-        std::isnan(m_layoutHeight)
+        std::isnan(boxHeight)
             ? std::max(minY,
                        totalHeight - paragraphSpace - topTrim - bottomTrim)
-            : minY + m_layoutHeight;
+            : minY + boxHeight;
     switch (effectiveSizing())
     {
         case TextSizing::autoWidth:
-            m_bounds =
-                AABB(0.0f,
-                     minY,
-                     std::isnan(m_layoutWidth) ? maxWidth : m_layoutWidth,
-                     autoSizeMaxY);
+            m_bounds = AABB(0.0f,
+                            minY,
+                            std::isnan(boxWidth) ? maxWidth : boxWidth,
+                            autoSizeMaxY);
             break;
         case TextSizing::autoHeight:
             m_bounds = AABB(0.0f, minY, effectiveWidth(), autoSizeMaxY);
@@ -1533,6 +1535,26 @@
 {}
 #endif
 
+StatusCode Text::import(ImportStack& importStack)
+{
+    // A layout has always sized the text itself (effectiveWidth/Height), but
+    // before 7.3 an auto-sized text still took its *box* from the content, so
+    // the box could disagree with the slot and every overflow mode stayed
+    // inert. Keep that for those files; newer ones box to the slot. See
+    // File::minorVersion.
+    int major = importStack.majorVersion();
+    int minor = importStack.minorVersion();
+    m_layoutSizesBox = major > 7 || (major == 7 && minor >= 3);
+    return Super::import(importStack);
+}
+
+Core* Text::clone() const
+{
+    Text* twin = TextBase::clone()->as<Text>();
+    twin->m_layoutSizesBox = m_layoutSizesBox;
+    return twin;
+}
+
 Vec2D Text::layoutBaseTranslation(LayoutParticipant* participant) const
 {
     assert(participant != nullptr);
diff --git a/tests/unit_tests/assets/layout/text_layout_7_3.riv b/tests/unit_tests/assets/layout/text_layout_7_3.riv
new file mode 100644
index 0000000..990bc5f
--- /dev/null
+++ b/tests/unit_tests/assets/layout/text_layout_7_3.riv
Binary files differ
diff --git a/tests/unit_tests/assets/layout/text_layout_pre_7_3.riv b/tests/unit_tests/assets/layout/text_layout_pre_7_3.riv
new file mode 100644
index 0000000..9e66720
--- /dev/null
+++ b/tests/unit_tests/assets/layout/text_layout_pre_7_3.riv
Binary files differ
diff --git a/tests/unit_tests/assets/layout_text_match.riv b/tests/unit_tests/assets/layout_text_match.riv
index a5592b4..dea469f 100644
--- a/tests/unit_tests/assets/layout_text_match.riv
+++ b/tests/unit_tests/assets/layout_text_match.riv
Binary files differ
diff --git a/tests/unit_tests/assets/layout_text_match_7_3.riv b/tests/unit_tests/assets/layout_text_match_7_3.riv
new file mode 100644
index 0000000..f8551a6
--- /dev/null
+++ b/tests/unit_tests/assets/layout_text_match_7_3.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/text_test.cpp b/tests/unit_tests/runtime/text_test.cpp
index 04c0cf5..2163923 100644
--- a/tests/unit_tests/runtime/text_test.cpp
+++ b/tests/unit_tests/runtime/text_test.cpp
@@ -928,10 +928,11 @@
     CHECK(silver.matches("text_vertical_trim_test"));
 }
 
-TEST_CASE("Text box matches layout-controlled size", "[silver]")
+// Renders a text/layout matrix for five frames against the named silver.
+static void checkTextLayoutSilver(const char* asset, const char* silverName)
 {
     rive::SerializingFactory silver;
-    auto file = ReadRiveFile("assets/layout_text_match.riv", &silver);
+    auto file = ReadRiveFile(asset, &silver);
 
     auto artboard = file->artboardDefault();
     REQUIRE(artboard != nullptr);
@@ -973,7 +974,45 @@
         artboard->draw(renderer.get());
     }
 
-    CHECK(silver.matches("layout_text_match"));
+    CHECK(silver.matches(silverName));
+}
+
+// These two assets are the same scene and differ only in their header's minor
+// version (see gen_layout_text_match.py), so any difference between the two
+// silvers is attributable to the Text::import gate and nothing else.
+//
+// Below 7.3, m_layoutSizesBox stays false: an auto-sized text keeps
+// content-sized bounds and every overflow mode stays inert, so the six
+// overflow modes in each half of the matrix all render identically.
+TEST_CASE("Text box keeps its content size before 7.3", "[silver]")
+{
+    checkTextLayoutSilver("assets/layout_text_match.riv", "layout_text_match");
+}
+
+// At 7.3 the box takes the layout's size and the overflow modes engage, so the
+// matrix fans out. Rows 15-18 cover verticalAlign middle/bottom over both a
+// taller box (minHeight) and a shorter one (maxHeight, where align and line
+// culling interact).
+TEST_CASE("Text box matches layout-controlled size", "[silver]")
+{
+    checkTextLayoutSilver("assets/layout_text_match_7_3.riv",
+                          "layout_text_match_7_3");
+}
+
+// The two assets are the same scene apart from the version stamp and two
+// inert ComponentOrigin children (the editor materialises those on selection;
+// neither file carries a pivotOrigin), so they are authored exports rather
+// than a generated pair.
+TEST_CASE("Middle-aligned hug-layout text before 7.3", "[silver]")
+{
+    checkTextLayoutSilver("assets/layout/text_layout_pre_7_3.riv",
+                          "text_layout_pre_7_3");
+}
+
+TEST_CASE("Middle-aligned hug-layout text at 7.3", "[silver]")
+{
+    checkTextLayoutSilver("assets/layout/text_layout_7_3.riv",
+                          "text_layout_7_3");
 }
 
 TEST_CASE("Fit font size with varying sizes", "[text]")
diff --git a/tests/unit_tests/silvers/data_viz_demo.sriv b/tests/unit_tests/silvers/data_viz_demo.sriv
index a1914ca..3198c38 100644
--- a/tests/unit_tests/silvers/data_viz_demo.sriv
+++ b/tests/unit_tests/silvers/data_viz_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/layout_text_match.sriv b/tests/unit_tests/silvers/layout_text_match.sriv
index 6831c3b..c3e9c36 100644
--- a/tests/unit_tests/silvers/layout_text_match.sriv
+++ b/tests/unit_tests/silvers/layout_text_match.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/layout_text_match_7_3.sriv b/tests/unit_tests/silvers/layout_text_match_7_3.sriv
new file mode 100644
index 0000000..9f346d0
--- /dev/null
+++ b/tests/unit_tests/silvers/layout_text_match_7_3.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/text_layout_7_3.sriv b/tests/unit_tests/silvers/text_layout_7_3.sriv
new file mode 100644
index 0000000..691a9ae
--- /dev/null
+++ b/tests/unit_tests/silvers/text_layout_7_3.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/text_layout_pre_7_3.sriv b/tests/unit_tests/silvers/text_layout_pre_7_3.sriv
new file mode 100644
index 0000000..5b4aeed
--- /dev/null
+++ b/tests/unit_tests/silvers/text_layout_pre_7_3.sriv
Binary files differ