From 2c2df3a57fd2c7862f89fb4d77a1e6880321b35a Mon Sep 17 00:00:00 2001 From: Don Gagne Date: Mon, 20 Jul 2026 20:37:51 -0700 Subject: [PATCH] fix(PlanView): reload mission item editors from tree root to avoid stale-delegate loads MissionItemEditor used a Connections on its missionItem to reload the editor Loader whenever isCurrentItemChanged fired. TreeView releases delegates with reuseItems: false by invalidating their QML context immediately while deferring object destruction via deleteLater. In that window the Connections to the persistent MissionItem still fires, and the Loader's setSource() then attempts a component load in the invalidated context, producing: QQmlContext: Cannot set context object on invalid context. QQmlComponent: Cannot create a component in an invalid context which fails PlanViewLayerUITest under strict-mode log checking. The race only triggers when delegate teardown (layer switch, group collapse, forceLayout) coincides with a current-item change - e.g. takeoff item insertion - and is far more likely on slow CI runners. Invert the control: remove the per-delegate Connections and have PlanTreeView (whose root outlives all delegates) react to MissionController::planViewStateChanged and call reloadEditor() only on delegates that are still live via itemAtCell(). Newly created delegates load their editor from Component.onCompleted as before. --- src/PlanView/MissionItemEditor.qml | 17 +++++++++-------- src/PlanView/PlanTreeView.qml | 28 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/PlanView/MissionItemEditor.qml b/src/PlanView/MissionItemEditor.qml index 07798d896cf9..2fc1d2ee0c26 100644 --- a/src/PlanView/MissionItemEditor.qml +++ b/src/PlanView/MissionItemEditor.qml @@ -43,8 +43,14 @@ Rectangle { readonly property real _trashSize: commandPicker.height * 0.75 readonly property bool _waypointsOnlyMode: QGroundControl.corePlugin.options.missionWaypointsOnly - // setSource() injects missionItem before internal bindings activate - function _loadEditor() { + // setSource() injects missionItem before internal bindings activate. + // Called on completion and by PlanTreeView when the current item changes. + // Intentionally NOT driven by a Connections to missionItem: missionItem + // outlives this delegate, and a released-but-not-yet-deleted delegate + // receiving isCurrentItemChanged would call setSource() on a Loader whose + // QML context is already invalidated ("Cannot create a component in an + // invalid context"). PlanTreeView only reaches live delegates. + function reloadEditor() { if (missionItem.isCurrentItem) { editorLoader.setSource(missionItem.editorQml, { missionItem: _root.missionItem, @@ -55,11 +61,6 @@ Rectangle { } } - Connections { - target: missionItem - function onIsCurrentItemChanged() { _root._loadEditor() } - } - QGCPalette { id: qgcPal colorGroupEnabled: enabled @@ -351,7 +352,7 @@ Rectangle { anchors.top: topRowLayout.bottom asynchronous: true - Component.onCompleted: _root._loadEditor() + Component.onCompleted: _root.reloadEditor() } onHeightChanged: { diff --git a/src/PlanView/PlanTreeView.qml b/src/PlanView/PlanTreeView.qml index cec8e83e6958..93ebed4178cb 100644 --- a/src/PlanView/PlanTreeView.qml +++ b/src/PlanView/PlanTreeView.qml @@ -52,6 +52,7 @@ TreeView { QGCFlickableScrollIndicator { parent: root; orientation: QGCFlickableScrollIndicator.Vertical } property int _lastMissionItemCount: 0 + property int _lastPlanViewSeqNum: -1 Connections { target: root._missionController.visualItems @@ -94,7 +95,29 @@ TreeView { root.editingLayerChangeRequested(PlanEditLayers.layerMission) } function onPlanViewStateChanged() { - // Current item changed — bring it on-screen if completely off-screen. + // planViewStateChanged is also emitted for forced recalcs (home position + // set, settings changes) where the current item is unchanged. Skip the + // editor reload in that case to avoid tearing down the current editor + // (and losing focus/UI state) for no reason. + if (_missionController.currentPlanViewSeqNum === root._lastPlanViewSeqNum) { + return + } + root._lastPlanViewSeqNum = _missionController.currentPlanViewSeqNum + + // Current item changed — reload the inline editors of the loaded + // mission item delegates. Driven from here rather than from a + // Connections inside MissionItemEditor because a released-but-not- + // yet-deleted delegate would still receive isCurrentItemChanged and + // trigger a Loader load in an invalidated QML context. Iterating + // the table only ever reaches live delegates. + for (let row = Math.max(root.topRow, 0); row <= root.bottomRow; row++) { + let delegateItem = root.itemAtCell(Qt.point(0, row)) + if (delegateItem && delegateItem.nodeType === "missionItem" && delegateItem.editorItem) { + delegateItem.editorItem.reloadEditor() + } + } + + // Bring the new current item on-screen if completely off-screen. // Fine-tuned scroll happens later via editorExpandedAndLoaded. var item = _missionController.currentPlanViewItem if (item) { @@ -218,6 +241,9 @@ TreeView { readonly property string nodeType: model.nodeType readonly property bool separator: model.separator ?? false + // Loaded editor item, reached by PlanTreeView's onPlanViewStateChanged + readonly property Item editorItem: loader.item + // In create-new-plan mode, only the Plan Info and Defaults groups and their children are enabled readonly property bool _enabledInCreateMode: nodeType === "planFileGroup" || nodeType === "planFileInfo" || nodeType === "defaultsGroup" || nodeType === "defaultsInfo"