aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorUlf Hermann <ulf.hermann@qt.io>2026-09-24 12:21:59 +0000
committerUlf Hermann <ulf.hermann@qt.io>2026-09-25 13:49:51 +0000
commitcddeb0d720e19322b459de35c73b4395da64929d (patch)
tree471ca767aa2ff28aa6d07a337e9dffd9ad977853
parent25e8fcab85180ad0c06b191a22d72c1de285a549 (diff)
QtQml: Keep dependency-less bindings alive while previewing
The object creator drops bindings that have no dependencies after their first evaluation since their values cannot change anymore. The QML preview, however, changes their code. With the binding gone, there is nothing to re-evaluate when patching in place, and the edit is silently ignored. For example, changing "duration: Math.abs(1000)" of a running animation to "duration: Math.abs(100)" had no effect. Keep such bindings alive if the preview is active. Pick-to: 6.12 Fixes: QTBUG-150561 Change-Id: I1cfcb0c3b778499d7173a4485bccbcd309f25f27 Reviewed-by: Mitch Curtis <mitch.curtis@qt.io> Reviewed-by: Olivier De Cannière <olivier.decanniere@qt.io>
-rw-r--r--src/qml/jsruntime/qv4engine.cpp5
-rw-r--r--src/qml/jsruntime/qv4engine_p.h2
-rw-r--r--src/qml/qml/qqmlobjectcreator.cpp6
-rw-r--r--tests/auto/qml/debugger/qqmlpreview/data/inplace_animation.qml30
-rw-r--r--tests/auto/qml/debugger/qqmlpreview/tst_qqmlpreview.cpp25
-rw-r--r--tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteral.qml20
-rw-r--r--tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteralOld.qml20
-rw-r--r--tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationOld.qml20
-rw-r--r--tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationScript.qml20
-rw-r--r--tests/auto/qml/debugger/qqmlpreviewdiff/tst_qqmlpreviewobjectpatch.cpp57
10 files changed, 203 insertions, 2 deletions
diff --git a/src/qml/jsruntime/qv4engine.cpp b/src/qml/jsruntime/qv4engine.cpp
index e984288ff3..d8dfd80459 100644
--- a/src/qml/jsruntime/qv4engine.cpp
+++ b/src/qml/jsruntime/qv4engine.cpp
@@ -946,6 +946,11 @@ void ExecutionEngine::setPreviewing(bool enabled)
hasPreview.storeRelease(enabled);
}
+bool ExecutionEngine::isPreviewing()
+{
+ return hasPreview.loadAcquire();
+}
+
#endif // QT_CONFIG(qml_debug)
void ExecutionEngine::initRootContext()
diff --git a/src/qml/jsruntime/qv4engine_p.h b/src/qml/jsruntime/qv4engine_p.h
index 67d0e847aa..69162701c8 100644
--- a/src/qml/jsruntime/qv4engine_p.h
+++ b/src/qml/jsruntime/qv4engine_p.h
@@ -549,6 +549,7 @@ public:
void setDebugger(Debugging::Debugger *) {}
void setProfiler(Profiling::Profiler *) {}
static void setPreviewing(bool) {}
+ static bool isPreviewing() { return false; }
#else
QV4::Debugging::Debugger *debugger() const { return m_debugger.data(); }
QV4::Profiling::Profiler *profiler() const { return m_profiler.data(); }
@@ -556,6 +557,7 @@ public:
void setDebugger(Debugging::Debugger *debugger);
void setProfiler(Profiling::Profiler *profiler);
static void setPreviewing(bool enabled);
+ static bool isPreviewing();
#endif // QT_CONFIG(qml_debug)
// We don't want to #include <private/qv4stackframe_p.h> here, but we still want
diff --git a/src/qml/qml/qqmlobjectcreator.cpp b/src/qml/qml/qqmlobjectcreator.cpp
index 0c646efb5a..36d693c01a 100644
--- a/src/qml/qml/qqmlobjectcreator.cpp
+++ b/src/qml/qml/qqmlobjectcreator.cpp
@@ -1693,7 +1693,9 @@ bool QQmlObjectCreator::finalize(QQmlInstantiationInterrupt &interrupt)
captured)?
If the answer to all of those questions is "no", it is safe to remove the binding, as there is no
way for it to change its value afterwards from that point on.
+ The QML preview, however, needs to re-evaluate the binding if its code is changed.
*/
+ const bool removeConstantBindings = !QV4::ExecutionEngine::isPreviewing();
while (!sharedState->allCreatedBindings.empty()) {
QQmlAbstractBinding::Ptr b = sharedState->allCreatedBindings.back();
@@ -1707,7 +1709,7 @@ bool QQmlObjectCreator::finalize(QQmlInstantiationInterrupt &interrupt)
data->clearPendingBindingBit(b->targetPropertyIndex().coreIndex());
b->setEnabled(true, QQmlPropertyData::BypassInterceptor |
QQmlPropertyData::DontRemoveBinding);
- if (b->kind() == QQmlAbstractBinding::QmlBinding) {
+ if (removeConstantBindings && b->kind() == QQmlAbstractBinding::QmlBinding) {
QQmlBinding *binding = static_cast<QQmlBinding*>(b.data());
if (!binding->hasError() && !binding->hasDependencies()
&& !binding->hasUnresolvedNames()) {
@@ -1741,7 +1743,7 @@ bool QQmlObjectCreator::finalize(QQmlInstantiationInterrupt &interrupt)
sharedState->allQPropertyBindings.pop_front();
// If the binding was actually not set, it's deleted now.
- if (success && bindingPrivateRefCount > 1) {
+ if (removeConstantBindings && success && bindingPrivateRefCount > 1) {
if (auto priv = QPropertyBindingPrivate::get(qmlBinding); priv->isQmlBinding()) {
const auto qmlBindingBase = static_cast<const QQmlPropertyBindingBase *>(priv);
if (qmlBindingBase->bindingKind()
diff --git a/tests/auto/qml/debugger/qqmlpreview/data/inplace_animation.qml b/tests/auto/qml/debugger/qqmlpreview/data/inplace_animation.qml
new file mode 100644
index 0000000000..31c8e9fdb4
--- /dev/null
+++ b/tests/auto/qml/debugger/qqmlpreview/data/inplace_animation.qml
@@ -0,0 +1,30 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+import QtQuick
+
+Item {
+ width: 200
+ height: 100
+
+ Rectangle {
+ width: 20
+ height: 20
+
+ NumberAnimation on x {
+ id: animation
+ from: 0
+ to: 180
+ duration: Math.abs(1000)
+ loops: Animation.Infinite
+ }
+ }
+
+ Timer {
+ repeat: true
+ interval: 100
+ running: true
+ onTriggered: console.log("animation duration=" + animation.duration
+ + " running=" + animation.running)
+ }
+}
diff --git a/tests/auto/qml/debugger/qqmlpreview/tst_qqmlpreview.cpp b/tests/auto/qml/debugger/qqmlpreview/tst_qqmlpreview.cpp
index 9fd7510ce0..ab8d929152 100644
--- a/tests/auto/qml/debugger/qqmlpreview/tst_qqmlpreview.cpp
+++ b/tests/auto/qml/debugger/qqmlpreview/tst_qqmlpreview.cpp
@@ -79,6 +79,7 @@ private slots:
void inPlaceUpdateConstant();
void inPlaceUpdateColor();
void inPlaceUpdateBindingChange();
+ void inPlaceUpdateConstantScriptBinding();
void inPlaceUpdatePropertyAdd();
void inPlaceUpdateBrokenFile();
void rerunAfterInPlaceUpdate();
@@ -868,6 +869,30 @@ void tst_QQmlPreview::inPlaceUpdateBindingChange()
QVERIFY(m_serviceErrors.isEmpty());
}
+// In-place update of a script binding without dependencies. Such a binding is usually dropped after
+// its first evaluation. Then there is nothing left to re-evaluate when its code changes
+// (QTBUG-150561).
+void tst_QQmlPreview::inPlaceUpdateConstantScriptBinding()
+{
+ const QString file("inplace_animation.qml");
+ QCOMPARE(startQmlProcess(file), ConnectSuccess);
+ QVERIFY(m_client);
+ QTRY_COMPARE(m_client->state(), QQmlDebugClient::Enabled);
+
+ enableInPlaceUpdates();
+ verifyProcessOutputContains("animation duration=1000 running=true");
+
+ QByteArray contents = readAndModify(file, {{"Math.abs(1000)", "Math.abs(100)"}});
+ serveFile(testFile(file), contents);
+ m_client->triggerLoad(testFileUrl(file));
+
+ verifyProcessOutputContains("animation duration=100 running=true");
+
+ m_process->stop();
+ QTRY_COMPARE(m_client->state(), QQmlDebugClient::NotConnected);
+ QVERIFY(m_serviceErrors.isEmpty());
+}
+
// In-place update: add a new property. This is a structural change that
// requires reattach.
void tst_QQmlPreview::inPlaceUpdatePropertyAdd()
diff --git a/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteral.qml b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteral.qml
new file mode 100644
index 0000000000..e8000dd018
--- /dev/null
+++ b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteral.qml
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+import QtQuick
+
+Item {
+ property alias animation: animation
+
+ Rectangle {
+ width: 20
+ height: 20
+
+ NumberAnimation on x {
+ id: animation
+ from: 0
+ to: 180
+ duration: 100
+ loops: Animation.Infinite
+ }
+ }
+}
diff --git a/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteralOld.qml b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteralOld.qml
new file mode 100644
index 0000000000..3a81586bbf
--- /dev/null
+++ b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationLiteralOld.qml
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+import QtQuick
+
+Item {
+ property alias animation: animation
+
+ Rectangle {
+ width: 20
+ height: 20
+
+ NumberAnimation on x {
+ id: animation
+ from: 0
+ to: 180
+ duration: 1000
+ loops: Animation.Infinite
+ }
+ }
+}
diff --git a/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationOld.qml b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationOld.qml
new file mode 100644
index 0000000000..a9edcbfee1
--- /dev/null
+++ b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationOld.qml
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+import QtQuick
+
+Item {
+ property alias animation: animation
+
+ Rectangle {
+ width: 20
+ height: 20
+
+ NumberAnimation on x {
+ id: animation
+ from: 0
+ to: 180
+ duration: Math.abs(1000)
+ loops: Animation.Infinite
+ }
+ }
+}
diff --git a/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationScript.qml b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationScript.qml
new file mode 100644
index 0000000000..3c87151199
--- /dev/null
+++ b/tests/auto/qml/debugger/qqmlpreviewdiff/data/LoopingAnimationDurationScript.qml
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+import QtQuick
+
+Item {
+ property alias animation: animation
+
+ Rectangle {
+ width: 20
+ height: 20
+
+ NumberAnimation on x {
+ id: animation
+ from: 0
+ to: 180
+ duration: Math.abs(100)
+ loops: Animation.Infinite
+ }
+ }
+}
diff --git a/tests/auto/qml/debugger/qqmlpreviewdiff/tst_qqmlpreviewobjectpatch.cpp b/tests/auto/qml/debugger/qqmlpreviewdiff/tst_qqmlpreviewobjectpatch.cpp
index e8981df6eb..b11abecbdd 100644
--- a/tests/auto/qml/debugger/qqmlpreviewdiff/tst_qqmlpreviewobjectpatch.cpp
+++ b/tests/auto/qml/debugger/qqmlpreviewdiff/tst_qqmlpreviewobjectpatch.cpp
@@ -26,6 +26,7 @@
#include <QtQuick/qquickitem.h>
#include <QtQuick/qquickwindow.h>
#include <private/qquickitem_p.h>
+#include <private/qquickanimation_p.h>
#include <QtGui/qfont.h>
#include <QtGui/qcolor.h>
@@ -36,6 +37,7 @@
#include <functional>
using namespace QV4::CompiledData;
+using namespace Qt::StringLiterals;
// A single observable value of a reloaded widget: how to read it, and what it should be
// before the reload, after the reload, and after mutating the source property (to prove the
@@ -207,6 +209,11 @@ private slots:
// Granular live-object update tests
void granularConstantUpdate();
void granularConstantUpdatePreservesUserOverride();
+
+ // A running looping animation picks up an edited duration on its next loop, just like when
+ // the duration is changed at run time (QTBUG-150561).
+ void inPlacePatchLoopingAnimationDuration_data();
+ void inPlacePatchLoopingAnimationDuration();
void granularPropertyAdditionWithStash();
void granularPropertyRemovalWithStash();
void updateObjectsFunctionAdd();
@@ -597,6 +604,56 @@ void tst_QQmlPreviewObjectPatch::granularConstantUpdatePreservesUserOverride()
QCOMPARE(object->property("count").toInt(), 99); // preserved
}
+void tst_QQmlPreviewObjectPatch::inPlacePatchLoopingAnimationDuration_data()
+{
+ QTest::addColumn<QString>("oldFile");
+ QTest::addColumn<QString>("newFile");
+
+ QTest::newRow("script") << u"LoopingAnimationDurationOld.qml"_s
+ << u"LoopingAnimationDurationScript.qml"_s;
+ QTest::newRow("literal") << u"LoopingAnimationDurationLiteralOld.qml"_s
+ << u"LoopingAnimationDurationLiteral.qml"_s;
+}
+
+void tst_QQmlPreviewObjectPatch::inPlacePatchLoopingAnimationDuration()
+{
+ QFETCH(QString, oldFile);
+ QFETCH(QString, newFile);
+
+ QQmlComponent oldComponent(&engine, testFileUrl(oldFile));
+ QVERIFY2(oldComponent.isReady(), qPrintable(oldComponent.errorString()));
+ QScopedPointer<QObject> object(oldComponent.create());
+ QVERIFY(object);
+
+ auto *animation = object->property("animation").value<QQuickPropertyAnimation *>();
+ QVERIFY(animation);
+ // QQuickAbstractAnimation::duration() is the duration of the running animation job.
+ QQuickAbstractAnimation *running = animation;
+ QVERIFY(animation->isRunning());
+ QCOMPARE(animation->duration(), 1000);
+ QCOMPARE(running->duration(), 1000);
+
+ QQmlComponent newComponent(&engine, testFileUrl(newFile));
+ QVERIFY2(newComponent.isReady(), qPrintable(newComponent.errorString()));
+
+ const auto oldExecUnit = QQmlComponentPrivate::get(&oldComponent)->compilationUnit();
+ const auto newExecUnit = QQmlComponentPrivate::get(&newComponent)->compilationUnit();
+ QVERIFY(oldExecUnit && newExecUnit);
+
+ auto objects = objectsForCompilationUnit(&engine, oldExecUnit);
+ QCOMPARE(updateObjects(objects, oldExecUnit, newExecUnit),
+ QQmlPreview::PatchResult::PatchedInPlace);
+
+ QCOMPARE(animation->duration(), 100);
+ QVERIFY(animation->isRunning());
+
+ // The next loop starts with the new duration, and the animation keeps going.
+ QTRY_COMPARE_WITH_TIMEOUT(running->duration(), 100, 5s);
+ QVERIFY(animation->isRunning());
+ const int currentTime = running->currentTime();
+ QTRY_VERIFY(running->currentTime() != currentTime);
+}
+
void tst_QQmlPreviewObjectPatch::granularPropertyAdditionWithStash()
{
QQmlComponent oldComponent(&engine, testFileUrl("GranularPropertyAddOld.qml"));