diff options
| author | Ulf Hermann <ulf.hermann@qt.io> | 2026-09-24 12:21:59 +0000 |
|---|---|---|
| committer | Ulf Hermann <ulf.hermann@qt.io> | 2026-09-25 13:49:51 +0000 |
| commit | cddeb0d720e19322b459de35c73b4395da64929d (patch) | |
| tree | 471ca767aa2ff28aa6d07a337e9dffd9ad977853 | |
| parent | 25e8fcab85180ad0c06b191a22d72c1de285a549 (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>
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")); |
