Skip to content

Commit 295a330

Browse files
authored
Remove widget reconciliation from SwiftUI sitemap update path (#1139)
updateUI now replaces currentPage wholesale instead of mutating existing OpenHABWidget objects through reconcileWidgets/copyWidgetProperties. Row inputs are already computed from fresh page data before any stored state changes, so the mutable widget graph was redundant as a rendering model. Deletes: - reconcileWidgets(_:with:) and copyWidgetProperties(from:to:) — 70 lines of in-place @published mutation that ran on every changed poll - injectSendCommand(for:) — the injected widget.sendCommand closure was never invoked; all row views call viewModel.sendCommand(command,for:itemName) directly via @EnvironmentObject - rowWidgetIndex / buildWidgetIndex / widget(for:) — the RowID→OpenHABWidget lookup was dead code; no row view called widget(for:) - WidgetRenderKey and its five support types — only used by trackWidgetUpdates which was removed in the previous commit loadCurrentPage drops the injectSendCommand call for the same reason. rebuildRowInputs drops the index rebuild. No behaviour change for any interactive row; slider optimistic values, segmented/selection sync, and media refresh all go through the row-input and item-name paths that were already in place. Signed-off-by: Tim Mueller-Seydlitz <timbms@gmail.com>
1 parent 106001d commit 295a330

2 files changed

Lines changed: 13 additions & 305 deletions

File tree

openHAB/Models/SitemapPageViewModel+SupportTypes.swift

Lines changed: 0 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -26,180 +26,3 @@ struct QueuedCommand {
2626
let version: Int
2727
}
2828

29-
// swiftlint:disable:next file_types_order
30-
struct WidgetRenderKey: Equatable {
31-
let label: String
32-
let icon: String
33-
let state: String
34-
let iconColor: String
35-
let labelColor: String
36-
let valueColor: String
37-
let url: String
38-
let period: String
39-
let service: String
40-
let legend: Bool?
41-
let refresh: Int
42-
let height: Double?
43-
let forceAsItem: Bool?
44-
let visibility: Bool
45-
let staticIcon: Bool?
46-
let switchSupport: Bool
47-
let minValue: Double
48-
let maxValue: Double
49-
let step: Double
50-
let pattern: String?
51-
let unit: String?
52-
let type: OpenHABWidget.WidgetType
53-
let linkedPageLink: String?
54-
let linkedPageTitle: String?
55-
let mappings: [WidgetMappingKey]
56-
let item: WidgetItemKey?
57-
let childWidgetIDs: [String]
58-
59-
static func from(widget: OpenHABWidget) -> WidgetRenderKey {
60-
WidgetRenderKey(
61-
label: widget.label,
62-
icon: widget.icon,
63-
state: widget.state,
64-
iconColor: widget.iconColor,
65-
labelColor: widget.labelcolor,
66-
valueColor: widget.valuecolor,
67-
url: widget.url,
68-
period: widget.period,
69-
service: widget.service,
70-
legend: widget.legend,
71-
refresh: widget.refresh,
72-
height: widget.height,
73-
forceAsItem: widget.forceAsItem,
74-
visibility: widget.visibility,
75-
staticIcon: widget.staticIcon,
76-
switchSupport: widget.switchSupport,
77-
minValue: widget.minValue,
78-
maxValue: widget.maxValue,
79-
step: widget.step,
80-
pattern: widget.pattern,
81-
unit: widget.unit,
82-
type: widget.type,
83-
linkedPageLink: widget.linkedPage?.link,
84-
linkedPageTitle: widget.linkedPage?.title,
85-
mappings: widget.mappings.map(WidgetMappingKey.init),
86-
item: WidgetItemKey.from(item: widget.item),
87-
childWidgetIDs: widget.widgets.map(\.widgetId)
88-
)
89-
}
90-
91-
// Could be synthesized automatically by compiler. But this takes too long
92-
static func == (lhs: WidgetRenderKey, rhs: WidgetRenderKey) -> Bool {
93-
lhs.label == rhs.label &&
94-
lhs.icon == rhs.icon &&
95-
lhs.state == rhs.state &&
96-
lhs.iconColor == rhs.iconColor &&
97-
lhs.labelColor == rhs.labelColor &&
98-
lhs.valueColor == rhs.valueColor &&
99-
lhs.url == rhs.url &&
100-
lhs.period == rhs.period &&
101-
lhs.service == rhs.service &&
102-
lhs.legend == rhs.legend &&
103-
lhs.refresh == rhs.refresh &&
104-
lhs.height == rhs.height &&
105-
lhs.forceAsItem == rhs.forceAsItem &&
106-
lhs.visibility == rhs.visibility &&
107-
lhs.staticIcon == rhs.staticIcon &&
108-
lhs.switchSupport == rhs.switchSupport &&
109-
lhs.minValue == rhs.minValue &&
110-
lhs.maxValue == rhs.maxValue &&
111-
lhs.step == rhs.step &&
112-
lhs.pattern == rhs.pattern &&
113-
lhs.unit == rhs.unit &&
114-
lhs.type == rhs.type &&
115-
lhs.linkedPageLink == rhs.linkedPageLink &&
116-
lhs.linkedPageTitle == rhs.linkedPageTitle &&
117-
lhs.mappings == rhs.mappings &&
118-
lhs.item == rhs.item &&
119-
lhs.childWidgetIDs == rhs.childWidgetIDs
120-
}
121-
}
122-
123-
struct WidgetMappingKey: Equatable {
124-
let command: String
125-
let label: String
126-
let row: Int?
127-
let column: Int?
128-
let icon: String?
129-
let releaseCommand: String?
130-
131-
init(_ mapping: OpenHABWidgetMapping) {
132-
command = mapping.command
133-
label = mapping.label
134-
row = mapping.row
135-
column = mapping.column
136-
icon = mapping.icon
137-
releaseCommand = mapping.releaseCommand
138-
}
139-
}
140-
141-
struct WidgetItemKey: Equatable {
142-
let name: String
143-
let state: String?
144-
let link: String
145-
let label: String
146-
let type: OpenHABItem.ItemType?
147-
let groupType: OpenHABItem.ItemType?
148-
let stateDescription: WidgetStateDescriptionKey?
149-
let commandOptions: [WidgetCommandOptionKey]
150-
151-
static func from(item: OpenHABItem?) -> WidgetItemKey? {
152-
guard let item else { return nil }
153-
return WidgetItemKey(
154-
name: item.name,
155-
state: item.state,
156-
link: item.link,
157-
label: item.label,
158-
type: item.type,
159-
groupType: item.groupType,
160-
stateDescription: WidgetStateDescriptionKey.from(stateDescription: item.stateDescription),
161-
commandOptions: item.commandDescription?.commandOptions.map(WidgetCommandOptionKey.init) ?? []
162-
)
163-
}
164-
}
165-
166-
struct WidgetStateDescriptionKey: Equatable {
167-
let minimum: Double
168-
let maximum: Double
169-
let step: Double
170-
let readOnly: Bool
171-
let numberPattern: String?
172-
let options: [WidgetOptionKey]
173-
174-
static func from(stateDescription: OpenHABStateDescription?) -> WidgetStateDescriptionKey? {
175-
guard let stateDescription else { return nil }
176-
return WidgetStateDescriptionKey(
177-
minimum: stateDescription.minimum,
178-
maximum: stateDescription.maximum,
179-
step: stateDescription.step,
180-
readOnly: stateDescription.readOnly,
181-
numberPattern: stateDescription.numberPattern,
182-
options: stateDescription.options.map(WidgetOptionKey.init)
183-
)
184-
}
185-
}
186-
187-
struct WidgetOptionKey: Equatable {
188-
let value: String
189-
let label: String
190-
191-
init(_ option: OpenHABOptions) {
192-
value = option.value
193-
label = option.label
194-
}
195-
}
196-
197-
struct WidgetCommandOptionKey: Equatable {
198-
let command: String
199-
let label: String?
200-
201-
init(_ option: OpenHABCommandOptions) {
202-
command = option.command
203-
label = option.label
204-
}
205-
}

openHAB/Models/SitemapPageViewModel.swift

Lines changed: 13 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ class SitemapPageViewModel: ObservableObject {
9696
private var commandStateResetTasks: [String: Task<Void, Never>] = [:]
9797
private var commandStateVersions: [String: Int] = [:]
9898
private var queuedCommands: [String: QueuedCommand] = [:]
99-
private var rowWidgetIndex: [RowID: OpenHABWidget] = [:]
10099
private var sliderValueOverrides: [String: Double] = [:]
101100
private var sliderOverrideResetTasks: [String: Task<Void, Never>] = [:]
102101
private var lastForegroundRefreshAt: Date = .distantPast
@@ -267,28 +266,12 @@ class SitemapPageViewModel: ObservableObject {
267266

268267
func rebuildRowInputs() {
269268
let pageKey = "\(defaultSitemap)|\(pageId)"
270-
let widgets = relevantWidgets
271-
let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: widgets)
272-
rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: widgets)
269+
let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: relevantWidgets)
273270
if inputs != rowInputs {
274271
rowInputs = inputs
275272
}
276273
}
277274

278-
private func buildWidgetIndex(pageKey: String, widgets: [OpenHABWidget]) -> [RowID: OpenHABWidget] {
279-
var occurrenceByWidgetID: [String: Int] = [:]
280-
var index: [RowID: OpenHABWidget] = [:]
281-
index.reserveCapacity(widgets.count)
282-
for widget in widgets {
283-
let identityWidgetID = SitemapRowInputMapper.rowIdentityWidgetID(for: widget)
284-
occurrenceByWidgetID[identityWidgetID, default: 0] += 1
285-
let occurrence = occurrenceByWidgetID[identityWidgetID]!
286-
let rowID = RowID(pageKey: pageKey, widgetId: identityWidgetID, occurrence: occurrence)
287-
index[rowID] = widget
288-
}
289-
return index
290-
}
291-
292275
/// Increments `widgetUpdateVersions` for each row whose content differs between `oldInputs`
293276
/// and `newInputs`, keyed by full row identity.
294277
func bumpWidgetVersions(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) {
@@ -303,10 +286,6 @@ class SitemapPageViewModel: ObservableObject {
303286
}
304287
}
305288

306-
func widget(for rowID: RowID) -> OpenHABWidget? {
307-
rowWidgetIndex[rowID]
308-
}
309-
310289
func widgetUpdateVersion(for rowID: RowID) -> Int {
311290
widgetUpdateVersions[rowID.rawValue] ?? 0
312291
}
@@ -547,7 +526,7 @@ extension SitemapPageViewModel {
547526

548527
let pageKey = "\(defaultSitemap)|\(pageId)"
549528

550-
// Snapshot what the list would render from the new data — before any widget mutation.
529+
// Snapshot new inputs from fresh page data — no widget object mutation.
551530
let incomingFiltered: [OpenHABWidget]
552531
if searchText.isEmpty {
553532
incomingFiltered = page.widgets
@@ -559,36 +538,23 @@ extension SitemapPageViewModel {
559538
let previewInputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: incomingFiltered)
560539

561540
let titleChanged = currentPage == nil || currentPage?.title != page.title
562-
let canSkipReconciliation = searchText.isEmpty && previewInputs == rowInputs && !titleChanged
563-
guard !canSkipReconciliation else {
541+
let inputsChanged = previewInputs != rowInputs
542+
543+
// When search is empty and nothing the UI renders has changed, skip the update entirely.
544+
if searchText.isEmpty, !inputsChanged, !titleChanged {
564545
_ = clearSyncedSliderOverrides(using: page.widgets)
565546
return
566547
}
567548

568-
// Something changed — reconcile widget objects and update stored state.
569-
let currentWidgets = currentPage?.widgets ?? []
570-
let structureChanged = currentWidgets.count != page.widgets.count
571-
|| !zip(currentWidgets, page.widgets).allSatisfy { $0.widgetId == $1.widgetId }
572-
let reconciledWidgets = reconcileWidgets(page.widgets, with: currentWidgets)
573-
injectSendCommand(for: reconciledWidgets)
574-
575-
if structureChanged || titleChanged {
576-
page.widgets = reconciledWidgets
577-
currentPage = page
578-
} else {
579-
currentPage?.widgets = reconciledWidgets
580-
}
581-
582-
_ = clearSyncedSliderOverrides(using: reconciledWidgets)
583-
584-
// Rebuild command-dispatch index from the now-current reconciled widgets.
585-
rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: relevantWidgets)
549+
// Replace currentPage wholesale — no in-place widget reconciliation.
550+
currentPage = page
586551

587-
// Bump widget versions only for rows whose content actually changed.
588-
bumpWidgetVersions(from: rowInputs, to: previewInputs)
552+
_ = clearSyncedSliderOverrides(using: page.widgets)
589553

590-
// Publish new row inputs — guaranteed to differ from current (checked above).
591-
rowInputs = previewInputs
554+
if inputsChanged {
555+
bumpWidgetVersions(from: rowInputs, to: previewInputs)
556+
rowInputs = previewInputs
557+
}
592558
}
593559

594560
private func clearSyncedSliderOverrides(using widgets: [OpenHABWidget]) -> Int {
@@ -661,80 +627,10 @@ extension SitemapPageViewModel {
661627
throw SitemapPageError.noData
662628
}
663629

664-
injectSendCommand(for: page.widgets)
665630
currentPage = page
666631
rebuildRowInputs()
667632
}
668633

669-
private func reconcileWidgets(_ newWidgets: [OpenHABWidget], with currentWidgets: [OpenHABWidget]) -> [OpenHABWidget] {
670-
var buckets: [String: [OpenHABWidget]] = [:]
671-
for widget in currentWidgets {
672-
buckets[widget.widgetId, default: []].append(widget)
673-
}
674-
675-
var reconciled: [OpenHABWidget] = []
676-
reconciled.reserveCapacity(newWidgets.count)
677-
678-
for newWidget in newWidgets {
679-
if var candidates = buckets[newWidget.widgetId], !candidates.isEmpty {
680-
let existing = candidates.removeFirst()
681-
buckets[newWidget.widgetId] = candidates
682-
683-
// Always copy server properties to avoid missing updates when
684-
// non-keyed fields change (for example group summary/state rows).
685-
let previousChildren = existing.widgets
686-
copyWidgetProperties(from: newWidget, to: existing)
687-
existing.widgets = reconcileWidgets(newWidget.widgets, with: previousChildren)
688-
689-
reconciled.append(existing)
690-
} else {
691-
reconciled.append(newWidget)
692-
}
693-
}
694-
695-
return reconciled
696-
}
697-
698-
private func copyWidgetProperties(from source: OpenHABWidget, to target: OpenHABWidget) {
699-
target.label = source.label
700-
target.icon = source.icon
701-
target.state = source.state
702-
target.type = source.type
703-
target.isLeaf = source.isLeaf
704-
target.item = source.item
705-
target.iconColor = source.iconColor
706-
target.labelcolor = source.labelcolor
707-
target.valuecolor = source.valuecolor
708-
target.url = source.url
709-
target.period = source.period
710-
target.service = source.service
711-
target.legend = source.legend
712-
target.refresh = source.refresh
713-
target.height = source.height
714-
target.forceAsItem = source.forceAsItem
715-
target.minValue = source.minValue
716-
target.maxValue = source.maxValue
717-
target.step = source.step
718-
target.pattern = source.pattern
719-
target.unit = source.unit
720-
target.switchSupport = source.switchSupport
721-
target.mappings = source.mappings
722-
target.linkedPage = source.linkedPage
723-
target.visibility = source.visibility
724-
target.staticIcon = source.staticIcon
725-
target.text = source.text
726-
target.inputHint = source.inputHint
727-
target.encoding = source.encoding
728-
target.labelSource = source.labelSource
729-
target.releaseOnly = source.releaseOnly
730-
target.row = source.row
731-
target.column = source.column
732-
target.releaseCommand = source.releaseCommand
733-
target.command = source.command
734-
target.stateless = source.stateless
735-
target.yAxisDecimalPattern = source.yAxisDecimalPattern
736-
}
737-
738634
private func shouldRetryLongPolling(after error: any Error) -> Bool {
739635
if let urlError = OpenAPIErrorInspector.underlyingURLError(from: error) {
740636
switch urlError.code {
@@ -757,17 +653,6 @@ extension SitemapPageViewModel {
757653
return false
758654
}
759655

760-
private func injectSendCommand(for widgets: [OpenHABWidget]) {
761-
for widget in widgets {
762-
widget.sendCommand = { [weak self] item, command in
763-
self?.sendCommand(item, commandToSend: command)
764-
}
765-
766-
// If widget has nested children (e.g., frames/groups), inject recursively
767-
injectSendCommand(for: widget.widgets)
768-
}
769-
}
770-
771656
@MainActor
772657
func pushSitemap(name: String, path: String?) async {
773658
defaultSitemap = name

0 commit comments

Comments
 (0)