From e357c1a72d1c257c2574aa1b741c3ea346840863 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:27:28 +0200 Subject: [PATCH 01/18] Fix: Render ForEach dynamic children - route generated content through a real private view core - derive child render identities from explicit collection keys - replace the empty-output known issue with regression coverage --- Sources/TUIkit/Views/ForEach.swift | 75 ++++++++++++++----- Sources/TUIkitView/Rendering/ChildInfo.swift | 23 ++++++ .../RuntimeCharacterizationTests.swift | 11 +-- 3 files changed, 84 insertions(+), 25 deletions(-) diff --git a/Sources/TUIkit/Views/ForEach.swift b/Sources/TUIkit/Views/ForEach.swift index 02a8242be..caef3e1c9 100644 --- a/Sources/TUIkit/Views/ForEach.swift +++ b/Sources/TUIkit/Views/ForEach.swift @@ -10,16 +10,8 @@ /// element. The collection elements must be `Identifiable` or an /// explicit ID key path must be provided. /// -/// ## Rendering -/// -/// `ForEach` has **no standalone rendering capability**. It declares -/// `body: Never` but does *not* conform to `Renderable`. On its own, -/// it would produce an empty ``FrameBuffer``. -/// -/// In practice, `ForEach` is always used inside a `@ViewBuilder` block -/// (e.g. within `VStack` or `HStack`). The builder's `buildArray` -/// method flattens it into a ``ViewArray``, which *is* `Renderable`. -/// This is the same pattern SwiftUI uses. +/// Each generated child is associated with the element's explicit ID. The +/// identity remains stable when elements are inserted, removed, or reordered. /// /// # Example with Identifiable /// @@ -75,13 +67,20 @@ public struct ForEach self.content = content } - /// Never called — `ForEach` is flattened into a ``ViewArray`` by - /// `@ViewBuilder.buildArray` before rendering occurs. - /// - /// - Important: Accessing this property directly will crash at runtime. - /// Always use `ForEach` inside a `@ViewBuilder` closure (e.g., `VStack`, `HStack`). - public var body: Never { - fatalError("ForEach has no standalone rendering; use inside a @ViewBuilder block") + public var body: some View { + core + } +} + +// MARK: - Dynamic Children + +extension ForEach: ChildInfoProvider, ChildViewProvider { + public func childInfos(context: RenderContext) -> [ChildInfo] { + core.childInfos(context: context) + } + + public func childViews(context: RenderContext) -> [ChildView] { + core.childViews(context: context) } } @@ -120,3 +119,45 @@ extension ForEach where Data == Range, ID == Int { self.content = content } } + +// MARK: - Private Core + +private extension ForEach { + var core: _ForEachCore { + _ForEachCore(data: data, idKeyPath: idKeyPath, content: content) + } +} + +private struct _ForEachCore: View, Renderable, + ChildInfoProvider, ChildViewProvider { + let data: Data + let idKeyPath: KeyPath + let content: (Data.Element) -> Content + + var body: Never { + fatalError("_ForEachCore renders its dynamic children directly") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) + } + + func childInfos(context: RenderContext) -> [ChildInfo] { + data.map { element in + let view = content(element) + return makeChildInfo( + for: view, + context: context.withKeyedChildIdentity( + type: Content.self, + key: element[keyPath: idKeyPath] + ) + ) + } + } + + func childViews(context: RenderContext) -> [ChildView] { + data.map { element in + ChildView(content(element), key: element[keyPath: idKeyPath]) + } + } +} diff --git a/Sources/TUIkitView/Rendering/ChildInfo.swift b/Sources/TUIkitView/Rendering/ChildInfo.swift index 425c0822a..a634698fe 100644 --- a/Sources/TUIkitView/Rendering/ChildInfo.swift +++ b/Sources/TUIkitView/Rendering/ChildInfo.swift @@ -68,6 +68,29 @@ public struct ChildView { } } + /// Creates a child wrapper whose identity is derived from an explicit key. + /// + /// Keyed children keep the same runtime identity when their collection is + /// inserted into or reordered. + package init(_ view: V, key: ID) { + if let spacer = view as? SpacerProtocol { + self.isSpacer = true + self.spacerMinLength = spacer.spacerMinLength + } else { + self.isSpacer = false + self.spacerMinLength = nil + } + + self._measure = { proposal, context in + let childContext = context.withKeyedChildIdentity(type: V.self, key: key) + return measureChild(view, proposal: proposal, context: childContext) + } + self._render = { width, height, context in + let childContext = context.withKeyedChildIdentity(type: V.self, key: key) + return renderChild(view, width: width, height: height, context: childContext) + } + } + /// Measures this child view without rendering. public func measure(proposal: ProposedSize, context: RenderContext) -> ViewSize { _measure(proposal, context) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index dc160283c..d7737e978 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -176,8 +176,8 @@ struct RuntimeCharacterizationTests { #expect(harness.trace.snapshot() == [.effect("preference committed")]) } - @Test("ForEach output remains characterized for issue #12") - func knownForEachOutputDefect() { + @Test("ForEach renders each element inside a stack") + func forEachRendersEachElement() { let harness = RuntimeCharacterizationHarness() let actualLines = harness.render { VStack { @@ -187,12 +187,7 @@ struct RuntimeCharacterizationTests { } }.ansiStrippedLines - withKnownIssue("Issue #12: ForEach is not rendered outside the List-specific path") { - #expect(actualLines == ["row:0", "row:1"]) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(actualLines == ["row:0", "row:1"]) } @Test("Reconstructed lifecycle modifier keeps one mounted identity") From 9e140571dc8f6c6b69afa659ec155d19b940f83a Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:30:14 +0200 Subject: [PATCH 02/18] Fix: Flatten dynamic children in tuple layouts - expand child providers between static tuple siblings - preserve structural scopes around type-erased children - cover horizontal ForEach composition --- Sources/TUIkitView/Core/TupleViews.swift | 31 ++++++++++++++------ Sources/TUIkitView/Rendering/ChildInfo.swift | 30 +++++++++++++++++++ Tests/TUIkitTests/ComponentViewTests.swift | 20 +++++++++---- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Sources/TUIkitView/Core/TupleViews.swift b/Sources/TUIkitView/Core/TupleViews.swift index c2c25d0bf..f42a942b6 100644 --- a/Sources/TUIkitView/Core/TupleViews.swift +++ b/Sources/TUIkitView/Core/TupleViews.swift @@ -52,12 +52,13 @@ extension TupleView: Renderable, ChildInfoProvider { public func childInfos(context: RenderContext) -> [ChildInfo] { var infos: [ChildInfo] = [] - repeat infos.append( - makeChildInfo( - for: each children, - context: context.withChildIdentity(type: type(of: each children), index: infos.count) - ) - ) + var childIndex = 0 + func append(_ child: Child) { + let childContext = context.withChildIdentity(type: Child.self, index: childIndex) + childIndex += 1 + infos.append(contentsOf: resolveChildInfos(from: child, context: childContext)) + } + repeat append(each children) return infos } } @@ -67,9 +68,21 @@ extension TupleView: Renderable, ChildInfoProvider { extension TupleView: ChildViewProvider { public func childViews(context: RenderContext) -> [ChildView] { var views: [ChildView] = [] - repeat views.append( - ChildView(each children, childIndex: views.count) - ) + var childIndex = 0 + func append(_ child: Child) { + let index = childIndex + childIndex += 1 + + if let provider = child as? ChildViewProvider { + let childContext = context.withChildIdentity(type: Child.self, index: index) + views.append(contentsOf: provider.childViews(context: childContext).map { + $0.scoped(to: childContext.identity) + }) + } else { + views.append(ChildView(child, childIndex: index)) + } + } + repeat append(each children) return views } } diff --git a/Sources/TUIkitView/Rendering/ChildInfo.swift b/Sources/TUIkitView/Rendering/ChildInfo.swift index a634698fe..9be7b1745 100644 --- a/Sources/TUIkitView/Rendering/ChildInfo.swift +++ b/Sources/TUIkitView/Rendering/ChildInfo.swift @@ -23,6 +23,18 @@ public struct ChildView { /// The minimum length of this spacer (only relevant if isSpacer is true). public let spacerMinLength: Int? + private init( + isSpacer: Bool, + spacerMinLength: Int?, + measure: @escaping (ProposedSize, RenderContext) -> ViewSize, + render: @escaping (Int, Int, RenderContext) -> FrameBuffer + ) { + self.isSpacer = isSpacer + self.spacerMinLength = spacerMinLength + self._measure = measure + self._render = render + } + public init(_ view: V) { if let spacer = view as? SpacerProtocol { self.isSpacer = true @@ -91,6 +103,24 @@ public struct ChildView { } } + /// Re-bases this type-erased child below a previously resolved identity. + package func scoped(to identity: ViewIdentity) -> Self { + Self( + isSpacer: isSpacer, + spacerMinLength: spacerMinLength, + measure: { proposal, context in + var scopedContext = context + scopedContext.identity = identity + return self.measure(proposal: proposal, context: scopedContext) + }, + render: { width, height, context in + var scopedContext = context + scopedContext.identity = identity + return self.render(width: width, height: height, context: scopedContext) + } + ) + } + /// Measures this child view without rendering. public func measure(proposal: ProposedSize, context: RenderContext) -> ViewSize { _measure(proposal, context) diff --git a/Tests/TUIkitTests/ComponentViewTests.swift b/Tests/TUIkitTests/ComponentViewTests.swift index 38a538581..264128ded 100644 --- a/Tests/TUIkitTests/ComponentViewTests.swift +++ b/Tests/TUIkitTests/ComponentViewTests.swift @@ -198,11 +198,21 @@ struct ForEachTests { #expect(generatedTexts == ["Alpha", "Beta"]) } - // NOTE: ForEach inside VStack/HStack cannot be tested via renderToBuffer - // directly. ForEach is flattened into ViewArray by @ViewBuilder.buildArray - // at compile time — not at render time. Direct construction in tests - // bypasses the builder, so ForEach remains unflattened and produces - // an empty buffer. This is expected behavior, matching SwiftUI's pattern. + @Test("ForEach expands between static HStack children") + func forEachExpandsInsideTupleContent() { + let items = [TestItem(id: "a", name: "Alpha"), TestItem(id: "b", name: "Beta")] + let stack = HStack(spacing: 1) { + Text("Before") + ForEach(items) { item in + Text(item.name) + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["Before Alpha Beta After"]) + } @Test("ForEach with empty array produces empty result") func forEachEmptyArray() { From 522c6ac60ca3049d68accd2c865988b84ec90ec8 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:32:04 +0200 Subject: [PATCH 03/18] Test: Verify keyed ForEach state lifecycle - preserve row state across collection reorder - confirm removed keyed state is released after the render pass - expose isolated harness state counts for runtime assertions --- .../RuntimeCharacterizationTests.swift | 45 +++++++++++++++++++ .../RuntimeCharacterizationHarness.swift | 4 ++ 2 files changed, 49 insertions(+) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index d7737e978..e3aa01f44 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -190,6 +190,38 @@ struct RuntimeCharacterizationTests { #expect(actualLines == ["row:0", "row:1"]) } + @Test("ForEach State follows IDs across reorder and removal") + func forEachStateFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + + let initial = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let reordered = harness.render { + VStack { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let removed = harness.render { + VStack { + ForEach([2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + + #expect(initial.ansiStrippedLines == ["1:1", "2:2"]) + #expect(reordered.ansiStrippedLines == ["2:2", "1:1"]) + #expect(removed.ansiStrippedLines == ["2:2"]) + #expect(harness.storedStateCount == 1) + } + @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() @@ -276,6 +308,19 @@ private struct StatefulCharacterizationView: View { } } +private struct StatefulForEachRow: View { + @State private var storedID = 0 + + let id: Int + + var body: some View { + if storedID == 0 { + storedID = id + } + return Text("\(id):\(storedID)") + } +} + private struct TaskStateCharacterizationView: View { @State private var taskHasRun = false diff --git a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift index 037c02976..3a02798cb 100644 --- a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift +++ b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift @@ -22,6 +22,10 @@ enum RuntimeTraceEvent: Sendable, Equatable { final class RuntimeCharacterizationHarness { let trace = TraceRecorder() + var storedStateCount: Int { + stateStorage.count + } + private let availableWidth: Int private let availableHeight: Int private let rootIdentity = ViewIdentity(path: "RuntimeCharacterizationRoot") From 43f9d049c59fe0f52dff51d4edc8c074bd06db70 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:36:00 +0200 Subject: [PATCH 04/18] Fix: Propagate dynamic children through builders - flatten active conditional branches into parent layouts - expand nested providers produced by native builder arrays - verify regular and lazy stack composition --- Sources/TUIkitView/Core/PrimitiveViews.swift | 66 +++++++++++++++++--- Tests/TUIkitTests/ComponentViewTests.swift | 15 +++++ Tests/TUIkitTests/LazyStacksTests.swift | 44 +++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/Sources/TUIkitView/Core/PrimitiveViews.swift b/Sources/TUIkitView/Core/PrimitiveViews.swift index a6f5a1514..24ded4c69 100644 --- a/Sources/TUIkitView/Core/PrimitiveViews.swift +++ b/Sources/TUIkitView/Core/PrimitiveViews.swift @@ -55,7 +55,7 @@ public enum ConditionalView: View { /// This type is used internally by `ViewBuilder` for for-in loops. /// /// ```swift -/// ForEach(items) { item in +/// for item in items { /// Text(item.name) /// } /// ``` @@ -143,19 +143,71 @@ extension ConditionalView: Renderable { } } +// MARK: - ConditionalView Child Traversal + +extension ConditionalView: ChildInfoProvider, ChildViewProvider { + public func childInfos(context: RenderContext) -> [ChildInfo] { + switch self { + case .trueContent(let content): + invalidateInactiveBranch("false", context: context) + return resolveChildInfos(from: content, context: context.withBranchIdentity("true")) + case .falseContent(let content): + invalidateInactiveBranch("true", context: context) + return resolveChildInfos(from: content, context: context.withBranchIdentity("false")) + } + } + + public func childViews(context: RenderContext) -> [ChildView] { + switch self { + case .trueContent(let content): + invalidateInactiveBranch("false", context: context) + return scopedChildViews(from: content, branch: "true", context: context) + case .falseContent(let content): + invalidateInactiveBranch("true", context: context) + return scopedChildViews(from: content, branch: "false", context: context) + } + } + + private func invalidateInactiveBranch(_ branch: String, context: RenderContext) { + context.environment.stateStorage?.invalidateDescendants(of: context.identity.branch(branch)) + } + + private func scopedChildViews( + from content: Content, + branch: String, + context: RenderContext + ) -> [ChildView] { + let branchContext = context.withBranchIdentity(branch) + return resolveChildViews(from: content, context: branchContext).map { + $0.scoped(to: branchContext.identity) + } + } +} + // MARK: - ViewArray Rendering -extension ViewArray: Renderable, ChildInfoProvider { +extension ViewArray: Renderable, ChildInfoProvider, ChildViewProvider { public func renderToBuffer(context: RenderContext) -> FrameBuffer { FrameBuffer(verticallyStacking: childInfos(context: context).compactMap(\.buffer)) } public func childInfos(context: RenderContext) -> [ChildInfo] { - elements.enumerated().map { index, element in - makeChildInfo( - for: element, - context: context.withChildIdentity(type: type(of: element), index: index) - ) + elements.enumerated().flatMap { index, element in + let childContext = context.withChildIdentity(type: Element.self, index: index) + return resolveChildInfos(from: element, context: childContext) + } + } + + public func childViews(context: RenderContext) -> [ChildView] { + elements.enumerated().flatMap { index, element in + guard let provider = element as? ChildViewProvider else { + return [ChildView(element, childIndex: index)] + } + + let childContext = context.withChildIdentity(type: Element.self, index: index) + return provider.childViews(context: childContext).map { + $0.scoped(to: childContext.identity) + } } } } diff --git a/Tests/TUIkitTests/ComponentViewTests.swift b/Tests/TUIkitTests/ComponentViewTests.swift index 264128ded..56c7a6954 100644 --- a/Tests/TUIkitTests/ComponentViewTests.swift +++ b/Tests/TUIkitTests/ComponentViewTests.swift @@ -214,6 +214,21 @@ struct ForEachTests { #expect(buffer.lines.map(\.stripped) == ["Before Alpha Beta After"]) } + @Test("ForEach expands through native builder arrays") + func forEachExpandsThroughBuilderArray() { + let stack = HStack(spacing: 1) { + for group in 1...2 { + ForEach(["A", "B"], id: \.self) { item in + Text("\(group)\(item)") + } + } + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["1A 1B 2A 2B"]) + } + @Test("ForEach with empty array produces empty result") func forEachEmptyArray() { let items: [TestItem] = [] diff --git a/Tests/TUIkitTests/LazyStacksTests.swift b/Tests/TUIkitTests/LazyStacksTests.swift index 3d70d66a4..ffadd0e7e 100644 --- a/Tests/TUIkitTests/LazyStacksTests.swift +++ b/Tests/TUIkitTests/LazyStacksTests.swift @@ -110,6 +110,30 @@ struct LazyVStackTests { #expect(buffer.isEmpty) } + + @Test("LazyVStack expands conditional ForEach children with stack spacing") + func expandsConditionalForEach() { + let includeItems = true + let stack = LazyVStack(spacing: 1) { + Text("Before") + if includeItems { + ForEach(["Alpha", "Beta"], id: \.self) { item in + Text(item) + } + } else { + EmptyView() + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.height == 7) + #expect(buffer.lines.contains { $0.contains("Before") }) + #expect(buffer.lines.contains { $0.contains("Alpha") }) + #expect(buffer.lines.contains { $0.contains("Beta") }) + #expect(buffer.lines.contains { $0.contains("After") }) + } } // MARK: - LazyHStack Tests @@ -187,6 +211,26 @@ struct LazyHStackTests { #expect(buffer.isEmpty) } + + @Test("LazyHStack expands conditional ForEach children horizontally") + func expandsConditionalForEach() { + let includeItems = true + let stack = LazyHStack(spacing: 1) { + Text("Before") + if includeItems { + ForEach(["Alpha", "Beta"], id: \.self) { item in + Text(item) + } + } else { + EmptyView() + } + Text("After") + } + + let buffer = renderToBuffer(stack, context: testContext()) + + #expect(buffer.lines.map(\.stripped) == ["Before Alpha Beta After"]) + } } // MARK: - Equatable Tests From a0634cc494e9125a6573527fbab8397a200d4fa0 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:40:59 +0200 Subject: [PATCH 05/18] Fix: Render list rows at keyed identities - derive each extracted row context from its ForEach ID - preserve row State across list reorder - cover the List-specific rendering path --- Sources/TUIkit/Views/ListRowExtractor.swift | 6 +++-- .../RuntimeCharacterizationTests.swift | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/TUIkit/Views/ListRowExtractor.swift b/Sources/TUIkit/Views/ListRowExtractor.swift index 2f11b8a3a..7baba71cf 100644 --- a/Sources/TUIkit/Views/ListRowExtractor.swift +++ b/Sources/TUIkit/Views/ListRowExtractor.swift @@ -44,8 +44,10 @@ extension ForEach: ListRowExtractor { // Extract badge if the view is wrapped in a BadgeModifier let badge = extractBadgeValue(from: view) - // Render the view - let buffer = TUIkit.renderToBuffer(view, context: context) + // Render each row below its explicit collection identity so state, + // lifecycle, and other runtime records follow the row across reorder. + let rowContext = context.withKeyedChildIdentity(type: Content.self, key: elementID) + let buffer = TUIkit.renderToBuffer(view, context: rowContext) guard let rowID = elementID as? RowID else { return nil } return ListRow(id: rowID, buffer: buffer, badge: badge) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index e3aa01f44..de997eb05 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -222,6 +222,32 @@ struct RuntimeCharacterizationTests { #expect(harness.storedStateCount == 1) } + @Test("List row State follows ForEach IDs") + func listRowStateFollowsForEachIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: Int? + + let initial = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + let reordered = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + + #expect(initial.contains("1:1")) + #expect(initial.contains("2:2")) + #expect(reordered.contains("2:2")) + #expect(reordered.contains("1:1")) + } + @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() From 94b532e5a7254247e9c843637762a2f7c4843148 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:41:26 +0200 Subject: [PATCH 06/18] Fix: Preserve collection focus by item ID - move the focused cursor with reordered rows - recover onto a selectable row after removal - keep List and Table selection actions bound to the focused ID --- Sources/TUIkit/Focus/ItemListHandler.swift | 38 ++++++++++++++++++-- Tests/TUIkitTests/ItemListHandlerTests.swift | 27 ++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/Sources/TUIkit/Focus/ItemListHandler.swift b/Sources/TUIkit/Focus/ItemListHandler.swift index 431766334..63ec85455 100644 --- a/Sources/TUIkit/Focus/ItemListHandler.swift +++ b/Sources/TUIkit/Focus/ItemListHandler.swift @@ -84,14 +84,22 @@ final class ItemListHandler: Focusable { /// Maps item indices to their IDs for selection management. /// /// Entries are `nil` for non-selectable rows (e.g. section headers/footers in List). - var itemIDs: [SelectionValue?] = [] + var itemIDs: [SelectionValue?] = [] { + didSet { + preserveFocusedItem(from: oldValue) + } + } /// The set of indices that can be selected and focused. /// /// Headers and footers have non-selectable indices (not in this set). /// Only content rows have indices in `selectableIndices`. /// When empty, all items are considered selectable (backward compatibility). - var selectableIndices: Set = [] + var selectableIndices: Set = [] { + didSet { + moveFocusToSelectableItemIfNeeded() + } + } /// Creates an item list handler. /// @@ -204,6 +212,32 @@ extension ItemListHandler { // MARK: - Navigation Helpers extension ItemListHandler { + /// Keeps the keyboard cursor attached to the same item when rows move. + private func preserveFocusedItem(from previousIDs: [SelectionValue?]) { + guard !itemIDs.isEmpty else { + focusedIndex = 0 + scrollOffset = 0 + return + } + + if previousIDs.indices.contains(focusedIndex), + let focusedID = previousIDs[focusedIndex], + let reorderedIndex = itemIDs.firstIndex(of: focusedID) { + focusedIndex = reorderedIndex + } else { + focusedIndex = min(focusedIndex, itemIDs.count - 1) + } + } + + /// Moves a removed or non-selectable cursor to the nearest valid row. + private func moveFocusToSelectableItemIfNeeded() { + guard !selectableIndices.isEmpty, + !selectableIndices.contains(focusedIndex) else { return } + + let orderedIndices = selectableIndices.sorted() + focusedIndex = orderedIndices.first(where: { $0 >= focusedIndex }) ?? orderedIndices.last ?? 0 + } + /// Moves focus by the given delta, optionally wrapping around. /// /// - Parameters: diff --git a/Tests/TUIkitTests/ItemListHandlerTests.swift b/Tests/TUIkitTests/ItemListHandlerTests.swift index 444737ea3..5e78d48c8 100644 --- a/Tests/TUIkitTests/ItemListHandlerTests.swift +++ b/Tests/TUIkitTests/ItemListHandlerTests.swift @@ -350,6 +350,33 @@ struct ItemListHandlerSelectionTests { #expect(handler.isFocused(at: 1) == true) #expect(handler.isFocused(at: 2) == false) } + + @Test("Focus and selection follow item IDs across reorder") + func focusAndSelectionFollowReorder() { + var selectedID: String? = "b" + let handler = ItemListHandler( + focusID: "test", + itemCount: 3, + viewportHeight: 3, + selectionMode: .single + ) + handler.itemIDs = ["a", "b", "c"] + handler.singleSelection = Binding( + get: { selectedID }, + set: { selectedID = $0 } + ) + handler.focusedIndex = 1 + + handler.itemIDs = ["c", "a", "b"] + + #expect(handler.focusedIndex == 2) + #expect(handler.isFocused(at: 2)) + #expect(handler.isSelected(at: 2)) + + _ = handler.handleKeyEvent(KeyEvent(key: .enter)) + + #expect(selectedID == nil) + } } // MARK: - Item List Handler Scroll Tests From cbf2c24f566e32da0d1d3450d9187261c6e4e06c Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:47:46 +0200 Subject: [PATCH 07/18] Fix: Diagnose duplicate collection IDs - materialize keyed collection entries with stable occurrence identities - emit deterministic per-runtime duplicate ID diagnostics - verify duplicate ForEach output and diagnostics --- .../Environment/RuntimeDiagnostics.swift | 65 ++++++++++++++ .../Environment/ServiceEnvironment.swift | 13 +++ Sources/TUIkit/Environment/TUIContext.swift | 10 +++ .../Rendering/KeyedCollectionSnapshot.swift | 84 +++++++++++++++++++ Sources/TUIkit/Views/ForEach.swift | 24 ++++-- Sources/TUIkit/Views/ListRowExtractor.swift | 9 +- .../RuntimeCharacterizationTests.swift | 34 ++++++++ .../RuntimeCharacterizationHarness.swift | 5 ++ 8 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 Sources/TUIkit/Environment/RuntimeDiagnostics.swift create mode 100644 Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift diff --git a/Sources/TUIkit/Environment/RuntimeDiagnostics.swift b/Sources/TUIkit/Environment/RuntimeDiagnostics.swift new file mode 100644 index 000000000..3af297769 --- /dev/null +++ b/Sources/TUIkit/Environment/RuntimeDiagnostics.swift @@ -0,0 +1,65 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// RuntimeDiagnostics.swift +// +// License: MIT + +import Foundation + +/// A deterministic framework diagnostic tied to a render-tree identity. +struct RuntimeDiagnostic: Hashable, Sendable, CustomStringConvertible { + let identity: ViewIdentity + let message: String + + var description: String { + "\(message) at \(identity.path)" + } +} + +/// Per-runtime diagnostic collector with optional output reporting. +final class RuntimeDiagnostics: @unchecked Sendable { + private struct State: Sendable { + var seen: Set = [] + var current: [RuntimeDiagnostic] = [] + } + + private let state = Lock(initialState: State()) + private let reporter: (@Sendable (RuntimeDiagnostic) -> Void)? + + init(reporter: (@Sendable (RuntimeDiagnostic) -> Void)? = nil) { + self.reporter = reporter + } + + var messages: [String] { + state.withLock { $0.current.map(\.description) } + } + + func beginRenderPass() { + state.withLock { diagnostics in + diagnostics.seen.removeAll(keepingCapacity: true) + diagnostics.current.removeAll(keepingCapacity: true) + } + } + + func emit(_ diagnostic: RuntimeDiagnostic) { + let shouldReport = state.withLock { diagnostics -> Bool in + guard diagnostics.seen.insert(diagnostic).inserted else { return false } + diagnostics.current.append(diagnostic) + return true + } + + if shouldReport { + reporter?(diagnostic) + } + } + + func reset() { + beginRenderPass() + } + + static func standardError() -> RuntimeDiagnostics { + RuntimeDiagnostics { diagnostic in + let line = "TUIkit warning: \(diagnostic.description)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + } +} diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift index 555709c29..06c299fdd 100644 --- a/Sources/TUIkit/Environment/ServiceEnvironment.swift +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -46,6 +46,13 @@ private struct PreferenceStorageKey: EnvironmentKey { static let defaultValue: PreferenceStorage? = nil } +// MARK: - Runtime Diagnostics + +/// EnvironmentKey for diagnostics emitted during view traversal. +private struct RuntimeDiagnosticsKey: EnvironmentKey { + static let defaultValue: RuntimeDiagnostics? = nil +} + // MARK: - Pulse Phase /// EnvironmentKey for the focus indicator breathing animation phase. @@ -119,6 +126,12 @@ extension EnvironmentValues { set { self[PreferenceStorageKey.self] = newValue } } + /// Diagnostics emitted by the runtime that owns this render tree. + var runtimeDiagnostics: RuntimeDiagnostics? { + get { self[RuntimeDiagnosticsKey.self] } + set { self[RuntimeDiagnosticsKey.self] = newValue } + } + /// The current breathing animation phase (0-1) for the focus indicator. var pulsePhase: Double { get { self[PulsePhaseKey.self] } diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index b55e5967d..f524d1668 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -366,6 +366,9 @@ final class TUIContext { /// Preference value collection during rendering. let preferences: PreferenceStorage + /// Diagnostics emitted while traversing this runtime's view tree. + let runtimeDiagnostics: RuntimeDiagnostics + /// Persistent `@State` value storage indexed by `ViewIdentity`. let stateStorage: StateStorage @@ -422,6 +425,7 @@ final class TUIContext { /// - lifecycle: The lifecycle manager to use. /// - keyEventDispatcher: The key event dispatcher to use. /// - preferences: The preference storage to use. + /// - runtimeDiagnostics: The diagnostic collector to use. /// - stateStorage: The state storage to use. /// - observationRegistry: The Observation registry to use. /// - renderCache: The render cache to use. @@ -438,6 +442,7 @@ final class TUIContext { lifecycle: LifecycleManager = LifecycleManager(), keyEventDispatcher: KeyEventDispatcher = KeyEventDispatcher(), preferences: PreferenceStorage = PreferenceStorage(), + runtimeDiagnostics: RuntimeDiagnostics = RuntimeDiagnostics(), stateStorage: StateStorage = StateStorage(), observationRegistry: ObservationRegistry = ObservationRegistry(), renderCache: RenderCache = RenderCache(), @@ -469,6 +474,7 @@ final class TUIContext { self.lifecycle = lifecycle self.keyEventDispatcher = keyEventDispatcher self.preferences = preferences + self.runtimeDiagnostics = runtimeDiagnostics self.stateStorage = stateStorage self.observationRegistry = observationRegistry self.renderCache = renderCache @@ -498,6 +504,7 @@ extension TUIContext { /// Creates a runtime backed by the user's persistent configuration. static func production() -> TUIContext { TUIContext( + runtimeDiagnostics: .standardError(), storageBackend: StorageDefaults.runtimeBackend, localizationService: LocalizationService() ) @@ -507,6 +514,7 @@ extension TUIContext { func beginRenderPass() { keyEventDispatcher.clearHandlers() preferences.beginRenderPass() + runtimeDiagnostics.beginRenderPass() focusManager.beginRenderPass() statusBar.clearSectionItems() appHeader.beginRenderPass() @@ -539,6 +547,7 @@ extension TUIContext { environment.renderCache = renderCache environment.renderInvalidationSink = appState environment.preferenceStorage = preferences + environment.runtimeDiagnostics = runtimeDiagnostics environment.localizationService = localizationService environment.notificationService = notificationService environment.storageBackend = storageBackend @@ -583,6 +592,7 @@ extension TUIContext { lifecycle.reset() keyEventDispatcher.clearHandlers() preferences.reset() + runtimeDiagnostics.reset() stateStorage.reset() observationRegistry.reset() renderCache.reset() diff --git a/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift b/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift new file mode 100644 index 000000000..a0410448a --- /dev/null +++ b/Sources/TUIkit/Rendering/KeyedCollectionSnapshot.swift @@ -0,0 +1,84 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// KeyedCollectionSnapshot.swift +// +// License: MIT + +/// A materialized keyed collection used by dynamic views and row containers. +/// +/// Construction is linear. Every entry receives an occurrence-disambiguated +/// identity while public selection APIs continue to use the original ID. +struct KeyedCollectionSnapshot { + struct Entry { + let element: Element + let id: ID + let identityKey: KeyedCollectionIdentity + } + + struct Duplicate { + let id: ID + let offsets: [Int] + } + + let entries: [Entry] + let duplicates: [Duplicate] + + init( + _ data: Data, + id idKeyPath: KeyPath + ) where Data.Element == Element { + var entries: [Entry] = [] + entries.reserveCapacity(data.underestimatedCount) + + var offsetsByID: [ID: [Int]] = [:] + var duplicateOrder: [ID] = [] + + for (offset, element) in data.enumerated() { + let id = element[keyPath: idKeyPath] + let occurrence = offsetsByID[id, default: []].count + offsetsByID[id, default: []].append(offset) + if occurrence == 1 { + duplicateOrder.append(id) + } + + entries.append( + Entry( + element: element, + id: id, + identityKey: KeyedCollectionIdentity(id: id, occurrence: occurrence) + ) + ) + } + + self.entries = entries + self.duplicates = duplicateOrder.compactMap { id in + guard let offsets = offsetsByID[id] else { return nil } + return Duplicate(id: id, offsets: offsets) + } + } +} + +/// Internal identity key that keeps duplicate occurrences deterministic. +struct KeyedCollectionIdentity: Hashable, CustomDebugStringConvertible { + let id: ID + let occurrence: Int + + var debugDescription: String { + "\(String(reflecting: id))#\(occurrence)" + } +} + +extension KeyedCollectionSnapshot { + func reportDuplicates(container: String, context: RenderContext) { + guard let diagnostics = context.environment.runtimeDiagnostics else { return } + + for duplicate in duplicates { + let offsets = duplicate.offsets.map(String.init).joined(separator: ", ") + diagnostics.emit( + RuntimeDiagnostic( + identity: context.identity, + message: "\(container) contains duplicate ID \(String(reflecting: duplicate.id)) at offsets \(offsets)" + ) + ) + } + } +} diff --git a/Sources/TUIkit/Views/ForEach.swift b/Sources/TUIkit/Views/ForEach.swift index caef3e1c9..84a6c03c1 100644 --- a/Sources/TUIkit/Views/ForEach.swift +++ b/Sources/TUIkit/Views/ForEach.swift @@ -84,6 +84,14 @@ extension ForEach: ChildInfoProvider, ChildViewProvider { } } +extension ForEach { + func keyedSnapshot(context: RenderContext) -> KeyedCollectionSnapshot { + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + return snapshot + } +} + // MARK: - ForEach with Identifiable extension ForEach where Data.Element: Identifiable, ID == Data.Element.ID { @@ -143,21 +151,27 @@ private struct _ForEachCore [ChildInfo] { - data.map { element in - let view = content(element) + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + + return snapshot.entries.map { entry in + let view = content(entry.element) return makeChildInfo( for: view, context: context.withKeyedChildIdentity( type: Content.self, - key: element[keyPath: idKeyPath] + key: entry.identityKey ) ) } } func childViews(context: RenderContext) -> [ChildView] { - data.map { element in - ChildView(content(element), key: element[keyPath: idKeyPath]) + let snapshot = KeyedCollectionSnapshot(data, id: idKeyPath) + snapshot.reportDuplicates(container: "ForEach", context: context) + + return snapshot.entries.map { entry in + ChildView(content(entry.element), key: entry.identityKey) } } } diff --git a/Sources/TUIkit/Views/ListRowExtractor.swift b/Sources/TUIkit/Views/ListRowExtractor.swift index 7baba71cf..2d2696686 100644 --- a/Sources/TUIkit/Views/ListRowExtractor.swift +++ b/Sources/TUIkit/Views/ListRowExtractor.swift @@ -37,19 +37,18 @@ protocol ListRowExtractor { extension ForEach: ListRowExtractor { func extractListRows(context: RenderContext) -> [ListRow] { - data.compactMap { element -> ListRow? in - let elementID = element[keyPath: idKeyPath] - let view = content(element) + keyedSnapshot(context: context).entries.compactMap { entry -> ListRow? in + let view = content(entry.element) // Extract badge if the view is wrapped in a BadgeModifier let badge = extractBadgeValue(from: view) // Render each row below its explicit collection identity so state, // lifecycle, and other runtime records follow the row across reorder. - let rowContext = context.withKeyedChildIdentity(type: Content.self, key: elementID) + let rowContext = context.withKeyedChildIdentity(type: Content.self, key: entry.identityKey) let buffer = TUIkit.renderToBuffer(view, context: rowContext) - guard let rowID = elementID as? RowID else { return nil } + guard let rowID = entry.id as? RowID else { return nil } return ListRow(id: rowID, buffer: buffer, badge: badge) } } diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index de997eb05..d7b96891a 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -3,6 +3,7 @@ // // License: MIT +import Foundation import Observation import Testing import TUIkitTestSupport @@ -248,6 +249,34 @@ struct RuntimeCharacterizationTests { #expect(reordered.contains("1:1")) } + @Test("ForEach reports duplicate IDs deterministically") + func forEachReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + let items = [ + DuplicateForEachItem(id: "duplicate", label: "First"), + DuplicateForEachItem(id: "unique", label: "Second"), + DuplicateForEachItem(id: "duplicate", label: "Third"), + ] + + let snapshot = harness.render { + VStack { + ForEach(items) { item in + Text(item.label) + } + } + } + + let renderedLines = snapshot.ansiStrippedLines.map { + $0.trimmingCharacters(in: .whitespaces) + } + #expect(renderedLines == ["First", "Second", "Third"]) + let message = harness.currentDiagnosticMessages.first ?? "" + #expect(harness.currentDiagnosticMessages.count == 1) + #expect(message.contains("ForEach")) + #expect(message.contains("duplicate")) + #expect(message.contains("0, 2")) + } + @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() @@ -347,6 +376,11 @@ private struct StatefulForEachRow: View { } } +private struct DuplicateForEachItem: Identifiable { + let id: String + let label: String +} + private struct TaskStateCharacterizationView: View { @State private var taskHasRun = false diff --git a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift index 3a02798cb..52b128b82 100644 --- a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift +++ b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift @@ -26,6 +26,10 @@ final class RuntimeCharacterizationHarness { stateStorage.count } + var currentDiagnosticMessages: [String] { + tuiContext.runtimeDiagnostics.messages + } + private let availableWidth: Int private let availableHeight: Int private let rootIdentity = ViewIdentity(path: "RuntimeCharacterizationRoot") @@ -67,6 +71,7 @@ final class RuntimeCharacterizationHarness { ) rethrows -> Result { keyEventDispatcher.clearHandlers() preferences.beginRenderPass() + tuiContext.runtimeDiagnostics.beginRenderPass() lifecycle.beginRenderPass() stateStorage.beginRenderPass() renderCache.beginRenderPass() From 1faa6e6b9ac5dbee6b0e465859ff4326d249b75e Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:49:29 +0200 Subject: [PATCH 08/18] Fix: Key table data by row identity - render table rows from the shared keyed snapshot - preserve raw IDs for focus and selection tracking - report duplicate table IDs without dropping rows --- Sources/TUIkit/Views/Table.swift | 12 ++++--- .../RuntimeCharacterizationTests.swift | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift index 4c344ab62..e95123f03 100644 --- a/Sources/TUIkit/Views/Table.swift +++ b/Sources/TUIkit/Views/Table.swift @@ -191,6 +191,8 @@ private struct _TableCore: View, Renderable wher func renderToBuffer(context: RenderContext) -> FrameBuffer { let palette = context.environment.palette let stateStorage = context.environment.stateStorage! + let snapshot = KeyedCollectionSnapshot(data, id: \.id) + snapshot.reportDuplicates(container: "Table", context: context) // Calculate available width inside container (subtract border + padding) let innerWidth = max(0, context.availableWidth - 4) @@ -207,7 +209,7 @@ private struct _TableCore: View, Renderable wher // Handle empty state let contentLines: [String] - if data.isEmpty { + if snapshot.entries.isEmpty { contentLines = [emptyPlaceholder] } else { // Calculate viewport height @@ -228,7 +230,7 @@ private struct _TableCore: View, Renderable wher for: handlerKey, default: ItemListHandler( focusID: persistedFocusID, - itemCount: data.count, + itemCount: snapshot.entries.count, viewportHeight: viewportHeight, selectionMode: selectionMode, canBeFocused: !isDisabled @@ -237,10 +239,10 @@ private struct _TableCore: View, Renderable wher let handler = handlerBox.value // Update handler with current values - handler.itemCount = data.count + handler.itemCount = snapshot.entries.count handler.viewportHeight = viewportHeight handler.canBeFocused = !isDisabled - handler.itemIDs = data.map { $0.id } + handler.itemIDs = snapshot.entries.map(\.id) // Assign selection bindings directly (type-safe, no AnyHashable conversion) handler.singleSelection = singleSelection @@ -263,7 +265,7 @@ private struct _TableCore: View, Renderable wher // Data rows let visibleRange = handler.visibleRange for rowIndex in visibleRange { - let item = data[rowIndex] + let item = snapshot.entries[rowIndex].element let isFocused = handler.isFocused(at: rowIndex) && tableHasFocus let isSelected = handler.isSelected(at: rowIndex) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index d7b96891a..57d36dab7 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -277,6 +277,36 @@ struct RuntimeCharacterizationTests { #expect(message.contains("0, 2")) } + @Test("Table reports duplicate IDs deterministically") + func tableReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: String? + let items = [ + DuplicateForEachItem(id: "duplicate", label: "First"), + DuplicateForEachItem(id: "unique", label: "Second"), + DuplicateForEachItem(id: "duplicate", label: "Third"), + ] + + let snapshot = harness.render { + Table( + items, + selection: Binding(get: { selection }, set: { selection = $0 }) + ) { + TableColumn("Label", value: \DuplicateForEachItem.label) + } + } + + let rendered = snapshot.ansiStrippedLines.joined(separator: "\n") + #expect(rendered.contains("First")) + #expect(rendered.contains("Second")) + #expect(rendered.contains("Third")) + let message = harness.currentDiagnosticMessages.first ?? "" + #expect(harness.currentDiagnosticMessages.count == 1) + #expect(message.contains("Table")) + #expect(message.contains("duplicate")) + #expect(message.contains("0, 2")) + } + @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() @@ -376,7 +406,7 @@ private struct StatefulForEachRow: View { } } -private struct DuplicateForEachItem: Identifiable { +private struct DuplicateForEachItem: Identifiable, Sendable { let id: String let label: String } From e35ba46310ce2e764770614f46971353942b1bc8 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 03:55:01 +0200 Subject: [PATCH 09/18] Test: Verify keyed lifecycle and focus - drive characterization renders through the complete runtime pass - prove lifecycle callbacks survive reorder and clean up on removal - prove focus and actions follow collection IDs --- .../RuntimeCharacterizationTests.swift | 105 ++++++++++++++++++ .../RuntimeCharacterizationHarness.swift | 55 +++++---- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index 57d36dab7..730ab2406 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -223,6 +223,111 @@ struct RuntimeCharacterizationTests { #expect(harness.storedStateCount == 1) } + @Test("ForEach lifecycle follows IDs across reorder and removal") + func forEachLifecycleFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + Text("\(id)") + .onAppear { + trace.record(.lifecycle("appear:\(id)")) + } + .onDisappear { + trace.record(.lifecycle("disappear:\(id)")) + } + } + } + } + _ = harness.render { + VStack { + ForEach([2, 1], id: \.self) { id in + Text("\(id)") + .onAppear { + trace.record(.lifecycle("appear:\(id)")) + } + .onDisappear { + trace.record(.lifecycle("disappear:\(id)")) + } + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:2") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:1")) == false) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + + _ = harness.render { + VStack { + ForEach([2], id: \.self) { id in + Text("\(id)") + .onAppear { + trace.record(.lifecycle("appear:\(id)")) + } + .onDisappear { + trace.record(.lifecycle("disappear:\(id)")) + } + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:1") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + #expect(harness.mountedLifecycleCallbackCount == 1) + } + + @Test("ForEach focus follows IDs across reorder and removal") + func forEachFocusFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + HStack { + ForEach([1, 2], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + #expect(harness.dispatchFocusEvent(KeyEvent(key: .tab))) + let focusedItemTwo = harness.currentFocusedID + let initialStateIdentities = Set(harness.storedStateIdentityPaths) + + _ = harness.render { + HStack { + ForEach([2, 1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(focusedItemTwo != nil) + #expect(harness.currentFocusedID == focusedItemTwo) + #expect(Set(harness.storedStateIdentityPaths) == initialStateIdentities) + #expect(harness.dispatchFocusEvent(KeyEvent(key: .enter))) + #expect(trace.snapshot().contains(.effect("activate:2"))) + + _ = harness.render { + HStack { + ForEach([1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(harness.currentFocusedID != focusedItemTwo) + #expect(harness.storedStateCount < initialStateIdentities.count) + #expect(Set(harness.storedStateIdentityPaths).isSubset(of: initialStateIdentities)) + } + @Test("List row State follows ForEach IDs") func listRowStateFollowsForEachIDs() { let harness = RuntimeCharacterizationHarness() diff --git a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift index 52b128b82..30db7f5ae 100644 --- a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift +++ b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift @@ -26,19 +26,36 @@ final class RuntimeCharacterizationHarness { stateStorage.count } + var storedStateIdentityPaths: [String] { + stateStorage.storedIdentities.map(\.path).sorted() + } + var currentDiagnosticMessages: [String] { tuiContext.runtimeDiagnostics.messages } + var mountedLifecycleCallbackCount: Int { + lifecycle.disappearCallbackCount + } + + var mountedTaskCount: Int { + lifecycle.taskCount + } + + var observationRegistrationCount: Int { + tuiContext.observationRegistry.count + } + + var currentFocusedID: String? { + tuiContext.focusManager.currentFocusedID + } + private let availableWidth: Int private let availableHeight: Int private let rootIdentity = ViewIdentity(path: "RuntimeCharacterizationRoot") private let lifecycle: LifecycleManager - private let keyEventDispatcher: KeyEventDispatcher - private let preferences: PreferenceStorage private let stateStorage: StateStorage - private let renderCache: RenderCache private let tuiContext: TUIContext init(availableWidth: Int = 80, availableHeight: Int = 24) { @@ -46,22 +63,13 @@ final class RuntimeCharacterizationHarness { self.availableHeight = availableHeight let lifecycle = LifecycleManager() - let keyEventDispatcher = KeyEventDispatcher() - let preferences = PreferenceStorage() let stateStorage = StateStorage() - let renderCache = RenderCache() self.lifecycle = lifecycle - self.keyEventDispatcher = keyEventDispatcher - self.preferences = preferences self.stateStorage = stateStorage - self.renderCache = renderCache self.tuiContext = TUIContext( lifecycle: lifecycle, - keyEventDispatcher: keyEventDispatcher, - preferences: preferences, - stateStorage: stateStorage, - renderCache: renderCache + stateStorage: stateStorage ) } @@ -69,17 +77,11 @@ final class RuntimeCharacterizationHarness { func withRenderPass( _ operation: (RenderContext) throws -> Result ) rethrows -> Result { - keyEventDispatcher.clearHandlers() - preferences.beginRenderPass() - tuiContext.runtimeDiagnostics.beginRenderPass() - lifecycle.beginRenderPass() - stateStorage.beginRenderPass() - renderCache.beginRenderPass() + tuiContext.beginRenderPass() defer { - lifecycle.endRenderPass() - stateStorage.endRenderPass() - renderCache.removeInactive() + tuiContext.focusManager.endRenderPass() + tuiContext.endRenderPass() } let context = RenderContext( @@ -128,6 +130,15 @@ final class RuntimeCharacterizationHarness { pass() } + @discardableResult + func dispatchFocusEvent(_ event: KeyEvent) -> Bool { + tuiContext.focusManager.dispatchKeyEvent(event) + } + + func consumePendingRenderInvalidations() -> [RenderInvalidation] { + tuiContext.appState.consumePendingCacheInvalidations() + } + func recordEffect(_ description: String) { trace.record(.effect(description)) } From 8b56ca9af05fa1c390792e3cf0bc13eeaa417c7e Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 04:01:44 +0200 Subject: [PATCH 10/18] Fix: Keep keyed runtime effects single-mounted - suppress lifecycle and task effects during measurement passes - verify tasks, Observation, lifecycle, and focus follow item IDs - confirm removed collection identities release runtime records --- .../TUIkit/Modifiers/LifecycleModifier.swift | 35 +- .../KeyedCollectionRuntimeTests.swift | 411 ++++++++++++++++++ .../RuntimeCharacterizationTests.swift | 253 ----------- .../RuntimeCharacterizationHarness.swift | 7 +- 4 files changed, 435 insertions(+), 271 deletions(-) create mode 100644 Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index c0d047668..cae769529 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -22,10 +22,12 @@ struct OnAppearModifier: View { extension OnAppearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.appear") - _ = context.environment.lifecycle!.recordAppear( - identity: scopedContext.identity, - action: action - ) + if !context.isMeasuring { + _ = context.environment.lifecycle!.recordAppear( + identity: scopedContext.identity, + action: action + ) + } return TUIkit.renderToBuffer(content, context: scopedContext) } @@ -49,10 +51,11 @@ struct OnDisappearModifier: View { extension OnDisappearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.disappear") - let lifecycle = context.environment.lifecycle! - - lifecycle.registerDisappear(identity: scopedContext.identity, action: action) - _ = lifecycle.recordAppear(identity: scopedContext.identity, action: {}) + if !context.isMeasuring { + let lifecycle = context.environment.lifecycle! + lifecycle.registerDisappear(identity: scopedContext.identity, action: action) + _ = lifecycle.recordAppear(identity: scopedContext.identity, action: {}) + } return TUIkit.renderToBuffer(content, context: scopedContext) } @@ -81,14 +84,14 @@ struct TaskModifier: View { extension TaskModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.task") - let lifecycle = context.environment.lifecycle! - - lifecycle.updateTask( - identity: scopedContext.identity, - id: MountedTaskID.value, - priority: priority, - operation: task - ) + if !context.isMeasuring { + context.environment.lifecycle!.updateTask( + identity: scopedContext.identity, + id: MountedTaskID.value, + priority: priority, + operation: task + ) + } return TUIkit.renderToBuffer(content, context: scopedContext) } diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift new file mode 100644 index 000000000..b67391186 --- /dev/null +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -0,0 +1,411 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// KeyedCollectionRuntimeTests.swift +// +// License: MIT + +import Foundation +import Observation +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +@MainActor +@Suite("Keyed Collection Runtime", .serialized) +struct KeyedCollectionRuntimeTests { + @Test("ForEach renders each element inside a stack") + func forEachRendersEachElement() { + let harness = RuntimeCharacterizationHarness() + let actualLines = harness.render { + VStack { + ForEach(0..<2) { index in + Text("row:\(index)") + } + } + }.ansiStrippedLines + + #expect(actualLines == ["row:0", "row:1"]) + } + + @Test("ForEach State follows IDs across reorder and removal") + func forEachStateFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + + let initial = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let reordered = harness.render { + VStack { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + let removed = harness.render { + VStack { + ForEach([2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } + + #expect(initial.ansiStrippedLines == ["1:1", "2:2"]) + #expect(reordered.ansiStrippedLines == ["2:2", "1:1"]) + #expect(removed.ansiStrippedLines == ["2:2"]) + #expect(harness.storedStateCount == 1) + } + + @Test("ForEach lifecycle follows IDs across reorder and removal") + func forEachLifecycleFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + VStack { + ForEach([1, 2], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + _ = harness.render { + VStack { + ForEach([2, 1], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:2") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:1")) == false) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + + _ = harness.render { + VStack { + ForEach([2], id: \.self) { id in + KeyedLifecycleRow(id: id, trace: trace) + } + } + } + + #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:1") }.count == 1) + #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + #expect(harness.mountedLifecycleCallbackCount == 1) + } + + @Test("ForEach focus follows IDs across reorder and removal") + func forEachFocusFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + + _ = harness.render { + HStack { + ForEach([1, 2], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + #expect(harness.dispatchFocusEvent(KeyEvent(key: .tab))) + let focusedItemTwo = harness.currentFocusedID + let initialStateIdentities = Set(harness.storedStateIdentityPaths) + + _ = harness.render { + HStack { + ForEach([2, 1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(focusedItemTwo != nil) + #expect(harness.currentFocusedID == focusedItemTwo) + #expect(Set(harness.storedStateIdentityPaths) == initialStateIdentities) + #expect(harness.dispatchFocusEvent(KeyEvent(key: .enter))) + #expect(trace.snapshot().contains(.effect("activate:2"))) + + _ = harness.render { + HStack { + ForEach([1], id: \.self) { id in + Button("Item \(id)") { + trace.record(.effect("activate:\(id)")) + } + } + } + } + + #expect(harness.currentFocusedID != focusedItemTwo) + #expect(harness.storedStateCount < initialStateIdentities.count) + #expect(Set(harness.storedStateIdentityPaths).isSubset(of: initialStateIdentities)) + } + + @Test("ForEach tasks follow IDs and cancel removed items", .timeLimit(.minutes(1))) + func forEachTasksFollowIDs() async { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + let first = KeyedTaskItem(id: 1) + let second = KeyedTaskItem(id: 2) + + _ = harness.render { + VStack { + ForEach([first, second]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + await first.started.wait() + await second.started.wait() + + _ = harness.render { + VStack { + ForEach([second, first]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + + #expect(trace.snapshot().filter { $0 == .task("start:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .task("start:2") }.count == 1) + #expect(harness.mountedTaskCount == 2) + + _ = harness.render { + VStack { + ForEach([second]) { item in + KeyedTaskRow(item: item, trace: trace) + } + } + } + await first.cancelled.wait() + + #expect(trace.snapshot().filter { $0 == .task("cancel:1") }.count == 1) + #expect(trace.snapshot().contains(.task("cancel:2")) == false) + #expect(harness.mountedTaskCount == 1) + + harness.unmount() + await second.cancelled.wait() + + #expect(harness.mountedTaskCount == 0) + } + + @Test("ForEach Observation follows IDs and releases removed items") + func forEachObservationFollowsIDs() { + let harness = RuntimeCharacterizationHarness() + let first = ObservedForEachItem(id: 1) + let second = ObservedForEachItem(id: 2) + + _ = harness.render { + VStack { + ForEach([first, second]) { item in + ObservedForEachRow(item: item) + } + } + } + let initialRegistrationCount = harness.observationRegistrationCount + first.model.value = 1 + let initialInvalidations = harness.consumePendingSubtreeInvalidations() + + #expect(initialInvalidations.count == 1) + + _ = harness.render { + VStack { + ForEach([second, first]) { item in + ObservedForEachRow(item: item) + } + } + } + first.model.value = 2 + let reorderedInvalidations = harness.consumePendingSubtreeInvalidations() + + #expect(harness.observationRegistrationCount == initialRegistrationCount) + #expect(reorderedInvalidations == initialInvalidations) + + _ = harness.render { + VStack { + ForEach([second]) { item in + ObservedForEachRow(item: item) + } + } + } + let remainingRegistrationCount = harness.observationRegistrationCount + first.model.value = 3 + + #expect(remainingRegistrationCount < initialRegistrationCount) + #expect(harness.consumePendingSubtreeInvalidations().isEmpty) + + second.model.value = 1 + #expect(harness.consumePendingSubtreeInvalidations().count == 1) + } + + @Test("List row State follows ForEach IDs") + func listRowStateFollowsForEachIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: Int? + + let initial = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + let reordered = harness.render { + List(selection: Binding(get: { selection }, set: { selection = $0 })) { + ForEach([2, 1], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + }.ansiStrippedLines.joined(separator: "\n") + + #expect(initial.contains("1:1")) + #expect(initial.contains("2:2")) + #expect(reordered.contains("2:2")) + #expect(reordered.contains("1:1")) + } + + @Test("ForEach reports duplicate IDs deterministically") + func forEachReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + let items = duplicateItems + + let snapshot = harness.render { + VStack { + ForEach(items) { item in + Text(item.label) + } + } + } + + let renderedLines = snapshot.ansiStrippedLines.map { + $0.trimmingCharacters(in: .whitespaces) + } + #expect(renderedLines == ["First", "Second", "Third"]) + expectDuplicateDiagnostic(in: harness, container: "ForEach") + } + + @Test("Table reports duplicate IDs deterministically") + func tableReportsDuplicateIDs() { + let harness = RuntimeCharacterizationHarness() + var selection: String? + + let snapshot = harness.render { + Table( + duplicateItems, + selection: Binding(get: { selection }, set: { selection = $0 }) + ) { + TableColumn("Label", value: \DuplicateForEachItem.label) + } + } + + let rendered = snapshot.ansiStrippedLines.joined(separator: "\n") + #expect(rendered.contains("First")) + #expect(rendered.contains("Second")) + #expect(rendered.contains("Third")) + expectDuplicateDiagnostic(in: harness, container: "Table") + } +} + +// MARK: - Helpers + +@MainActor +private func expectDuplicateDiagnostic( + in harness: RuntimeCharacterizationHarness, + container: String +) { + let message = harness.currentDiagnosticMessages.first ?? "" + #expect(harness.currentDiagnosticMessages.count == 1) + #expect(message.contains(container)) + #expect(message.contains("duplicate")) + #expect(message.contains("0, 2")) +} + +private let duplicateItems = [ + DuplicateForEachItem(id: "duplicate", label: "First"), + DuplicateForEachItem(id: "unique", label: "Second"), + DuplicateForEachItem(id: "duplicate", label: "Third"), +] + +// MARK: - Fixtures + +private struct StatefulForEachRow: View { + @State private var storedID = 0 + + let id: Int + + var body: some View { + if storedID == 0 { + storedID = id + } + return Text("\(id):\(storedID)") + } +} + +private struct DuplicateForEachItem: Identifiable, Sendable { + let id: String + let label: String +} + +private struct KeyedTaskItem: Identifiable, Sendable { + let id: Int + let started = AsyncSignal() + let cancelled = AsyncSignal() +} + +private struct KeyedLifecycleRow: View { + let id: Int + let trace: TraceRecorder + + var body: some View { + Text("\(id)") + .onAppear { + trace.record(.lifecycle("appear:\(id)")) + } + .onDisappear { + trace.record(.lifecycle("disappear:\(id)")) + } + } +} + +private struct KeyedTaskRow: View { + let item: KeyedTaskItem + let trace: TraceRecorder + + var body: some View { + Text("task:\(item.id)") + .task { + trace.record(.task("start:\(item.id)")) + item.started.signal() + + await withTaskCancellationHandler { + try? await Task.sleep(nanoseconds: UInt64.max) + } onCancel: { + trace.record(.task("cancel:\(item.id)")) + item.cancelled.signal() + } + } + } +} + +private struct ObservedForEachItem: Identifiable { + let id: Int + let model = KeyedObservationModel() +} + +private struct ObservedForEachRow: View { + let item: ObservedForEachItem + + var body: some View { + Text("\(item.id):\(item.model.value)") + } +} + +@Observable +private final class KeyedObservationModel { + var value = 0 +} diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index 730ab2406..70aec56f5 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -177,241 +177,6 @@ struct RuntimeCharacterizationTests { #expect(harness.trace.snapshot() == [.effect("preference committed")]) } - @Test("ForEach renders each element inside a stack") - func forEachRendersEachElement() { - let harness = RuntimeCharacterizationHarness() - let actualLines = harness.render { - VStack { - ForEach(0..<2) { index in - Text("row:\(index)") - } - } - }.ansiStrippedLines - - #expect(actualLines == ["row:0", "row:1"]) - } - - @Test("ForEach State follows IDs across reorder and removal") - func forEachStateFollowsIDs() { - let harness = RuntimeCharacterizationHarness() - - let initial = harness.render { - VStack { - ForEach([1, 2], id: \.self) { id in - StatefulForEachRow(id: id) - } - } - } - let reordered = harness.render { - VStack { - ForEach([2, 1], id: \.self) { id in - StatefulForEachRow(id: id) - } - } - } - let removed = harness.render { - VStack { - ForEach([2], id: \.self) { id in - StatefulForEachRow(id: id) - } - } - } - - #expect(initial.ansiStrippedLines == ["1:1", "2:2"]) - #expect(reordered.ansiStrippedLines == ["2:2", "1:1"]) - #expect(removed.ansiStrippedLines == ["2:2"]) - #expect(harness.storedStateCount == 1) - } - - @Test("ForEach lifecycle follows IDs across reorder and removal") - func forEachLifecycleFollowsIDs() { - let harness = RuntimeCharacterizationHarness() - let trace = harness.trace - - _ = harness.render { - VStack { - ForEach([1, 2], id: \.self) { id in - Text("\(id)") - .onAppear { - trace.record(.lifecycle("appear:\(id)")) - } - .onDisappear { - trace.record(.lifecycle("disappear:\(id)")) - } - } - } - } - _ = harness.render { - VStack { - ForEach([2, 1], id: \.self) { id in - Text("\(id)") - .onAppear { - trace.record(.lifecycle("appear:\(id)")) - } - .onDisappear { - trace.record(.lifecycle("disappear:\(id)")) - } - } - } - } - - #expect(trace.snapshot().filter { $0 == .lifecycle("appear:1") }.count == 1) - #expect(trace.snapshot().filter { $0 == .lifecycle("appear:2") }.count == 1) - #expect(trace.snapshot().contains(.lifecycle("disappear:1")) == false) - #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) - - _ = harness.render { - VStack { - ForEach([2], id: \.self) { id in - Text("\(id)") - .onAppear { - trace.record(.lifecycle("appear:\(id)")) - } - .onDisappear { - trace.record(.lifecycle("disappear:\(id)")) - } - } - } - } - - #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:1") }.count == 1) - #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) - #expect(harness.mountedLifecycleCallbackCount == 1) - } - - @Test("ForEach focus follows IDs across reorder and removal") - func forEachFocusFollowsIDs() { - let harness = RuntimeCharacterizationHarness() - let trace = harness.trace - - _ = harness.render { - HStack { - ForEach([1, 2], id: \.self) { id in - Button("Item \(id)") { - trace.record(.effect("activate:\(id)")) - } - } - } - } - #expect(harness.dispatchFocusEvent(KeyEvent(key: .tab))) - let focusedItemTwo = harness.currentFocusedID - let initialStateIdentities = Set(harness.storedStateIdentityPaths) - - _ = harness.render { - HStack { - ForEach([2, 1], id: \.self) { id in - Button("Item \(id)") { - trace.record(.effect("activate:\(id)")) - } - } - } - } - - #expect(focusedItemTwo != nil) - #expect(harness.currentFocusedID == focusedItemTwo) - #expect(Set(harness.storedStateIdentityPaths) == initialStateIdentities) - #expect(harness.dispatchFocusEvent(KeyEvent(key: .enter))) - #expect(trace.snapshot().contains(.effect("activate:2"))) - - _ = harness.render { - HStack { - ForEach([1], id: \.self) { id in - Button("Item \(id)") { - trace.record(.effect("activate:\(id)")) - } - } - } - } - - #expect(harness.currentFocusedID != focusedItemTwo) - #expect(harness.storedStateCount < initialStateIdentities.count) - #expect(Set(harness.storedStateIdentityPaths).isSubset(of: initialStateIdentities)) - } - - @Test("List row State follows ForEach IDs") - func listRowStateFollowsForEachIDs() { - let harness = RuntimeCharacterizationHarness() - var selection: Int? - - let initial = harness.render { - List(selection: Binding(get: { selection }, set: { selection = $0 })) { - ForEach([1, 2], id: \.self) { id in - StatefulForEachRow(id: id) - } - } - }.ansiStrippedLines.joined(separator: "\n") - let reordered = harness.render { - List(selection: Binding(get: { selection }, set: { selection = $0 })) { - ForEach([2, 1], id: \.self) { id in - StatefulForEachRow(id: id) - } - } - }.ansiStrippedLines.joined(separator: "\n") - - #expect(initial.contains("1:1")) - #expect(initial.contains("2:2")) - #expect(reordered.contains("2:2")) - #expect(reordered.contains("1:1")) - } - - @Test("ForEach reports duplicate IDs deterministically") - func forEachReportsDuplicateIDs() { - let harness = RuntimeCharacterizationHarness() - let items = [ - DuplicateForEachItem(id: "duplicate", label: "First"), - DuplicateForEachItem(id: "unique", label: "Second"), - DuplicateForEachItem(id: "duplicate", label: "Third"), - ] - - let snapshot = harness.render { - VStack { - ForEach(items) { item in - Text(item.label) - } - } - } - - let renderedLines = snapshot.ansiStrippedLines.map { - $0.trimmingCharacters(in: .whitespaces) - } - #expect(renderedLines == ["First", "Second", "Third"]) - let message = harness.currentDiagnosticMessages.first ?? "" - #expect(harness.currentDiagnosticMessages.count == 1) - #expect(message.contains("ForEach")) - #expect(message.contains("duplicate")) - #expect(message.contains("0, 2")) - } - - @Test("Table reports duplicate IDs deterministically") - func tableReportsDuplicateIDs() { - let harness = RuntimeCharacterizationHarness() - var selection: String? - let items = [ - DuplicateForEachItem(id: "duplicate", label: "First"), - DuplicateForEachItem(id: "unique", label: "Second"), - DuplicateForEachItem(id: "duplicate", label: "Third"), - ] - - let snapshot = harness.render { - Table( - items, - selection: Binding(get: { selection }, set: { selection = $0 }) - ) { - TableColumn("Label", value: \DuplicateForEachItem.label) - } - } - - let rendered = snapshot.ansiStrippedLines.joined(separator: "\n") - #expect(rendered.contains("First")) - #expect(rendered.contains("Second")) - #expect(rendered.contains("Third")) - let message = harness.currentDiagnosticMessages.first ?? "" - #expect(harness.currentDiagnosticMessages.count == 1) - #expect(message.contains("Table")) - #expect(message.contains("duplicate")) - #expect(message.contains("0, 2")) - } - @Test("Reconstructed lifecycle modifier keeps one mounted identity") func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() @@ -498,24 +263,6 @@ private struct StatefulCharacterizationView: View { } } -private struct StatefulForEachRow: View { - @State private var storedID = 0 - - let id: Int - - var body: some View { - if storedID == 0 { - storedID = id - } - return Text("\(id):\(storedID)") - } -} - -private struct DuplicateForEachItem: Identifiable, Sendable { - let id: String - let label: String -} - private struct TaskStateCharacterizationView: View { @State private var taskHasRun = false diff --git a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift index 30db7f5ae..8a100c0af 100644 --- a/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift +++ b/Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift @@ -135,8 +135,11 @@ final class RuntimeCharacterizationHarness { tuiContext.focusManager.dispatchKeyEvent(event) } - func consumePendingRenderInvalidations() -> [RenderInvalidation] { - tuiContext.appState.consumePendingCacheInvalidations() + func consumePendingSubtreeInvalidations() -> [ViewIdentity] { + tuiContext.appState.consumePendingCacheInvalidations().compactMap { invalidation in + guard case .subtree(let identity) = invalidation else { return nil } + return identity + } } func recordEffect(_ description: String) { From c5d1db9ef92b990db322c5936d6277f4157825b7 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 04:04:33 +0200 Subject: [PATCH 11/18] Test: Validate ForEach benchmark output - enable the collection performance fixtures - assert exact regular and lazy stack layout before timing - preserve linear scaling and comparison checks --- Tests/TUIkitTests/RenderBottleneckTests.swift | 27 +++++++++---------- .../TUIkitTests/RenderPerformanceTests.swift | 14 +++++----- .../Support/PerformanceOutputAssertions.swift | 18 +++++++++++++ 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/Tests/TUIkitTests/RenderBottleneckTests.swift b/Tests/TUIkitTests/RenderBottleneckTests.swift index df5a39418..fbc1e1762 100644 --- a/Tests/TUIkitTests/RenderBottleneckTests.swift +++ b/Tests/TUIkitTests/RenderBottleneckTests.swift @@ -163,10 +163,7 @@ struct RenderBottleneckTests { // MARK: - ForEach Analysis - @Test( - "Analyze ForEach iteration count impact", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("Analyze ForEach iteration count impact") func analyzeForEachIterations() throws { let context = testContext() let iterations = 200 @@ -202,16 +199,16 @@ struct RenderBottleneckTests { } try requireRenderedOutput(forEach5, context: context) { - $0.strippedLines == items5.map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items5.map { "Row \($0)" }) } try requireRenderedOutput(forEach20, context: context) { - $0.strippedLines == items20.map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items20.map { "Row \($0)" }) } try requireRenderedOutput(forEach50, context: context) { - $0.strippedLines == items50.prefix(context.availableHeight).map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items50.map { "Row \($0)" }) } try requireRenderedOutput(forEach100, context: context) { - $0.strippedLines == items100.prefix(context.availableHeight).map { "Row \($0)" } + $0.strippedLines == expectedCenteredLines(items100.map { "Row \($0)" }) } _ = measure("5 items", iterations: iterations) { @@ -437,10 +434,7 @@ struct RenderBottleneckTests { // MARK: - LazyStack vs Regular Stack - @Test( - "Compare LazyStack vs regular Stack performance", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("Compare LazyStack vs regular Stack performance") func compareLazyVsRegular() throws { let iterations = 300 @@ -461,12 +455,15 @@ struct RenderBottleneckTests { } } - let expectedOutput = items.prefix(smallContext.availableHeight).map { "Row \($0)" } + let lines = items.map { "Row \($0)" } try requireRenderedOutput(regularStack, context: smallContext) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines(lines) } try requireRenderedOutput(lazyStack, context: smallContext) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines( + lines, + visibleCount: smallContext.availableHeight + ) } let regularTime = measure("VStack (100 items, 10 visible)", iterations: iterations) { diff --git a/Tests/TUIkitTests/RenderPerformanceTests.swift b/Tests/TUIkitTests/RenderPerformanceTests.swift index 817e88775..39147f095 100644 --- a/Tests/TUIkitTests/RenderPerformanceTests.swift +++ b/Tests/TUIkitTests/RenderPerformanceTests.swift @@ -310,10 +310,7 @@ struct RenderPerformanceTests { // MARK: - Comparative Tests - @Test( - "VStack vs LazyVStack performance comparison", - .disabled("Issue #12 must restore ForEach output before timing it") - ) + @Test("VStack vs LazyVStack performance comparison") func vstackVsLazyVstackComparison() throws { let items = Array(0..<10) let regularStack = VStack { @@ -329,12 +326,15 @@ struct RenderPerformanceTests { } let context = testContext(height: 5) // Only 5 lines visible - let expectedOutput = items.prefix(5).map { "Row \($0)" } + let lines = items.map { "Row \($0)" } try requireRenderedOutput(regularStack, context: context) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines(lines) } try requireRenderedOutput(lazyStack, context: context) { - $0.strippedLines == expectedOutput + $0.strippedLines == expectedCenteredLines( + lines, + visibleCount: context.availableHeight + ) } let regularTime = measureRenderTime(regularStack, iterations: 500, context: context) diff --git a/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift b/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift index 100ed25bc..0285aa527 100644 --- a/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift +++ b/Tests/TUIkitTests/Support/PerformanceOutputAssertions.swift @@ -29,3 +29,21 @@ func requireRenderedOutput( ) try #require(predicate(output)) } + +/// Produces the exact centered line layout used by vertical stacks. +func expectedCenteredLines( + _ lines: [String], + visibleCount: Int? = nil +) -> [String] { + let width = lines.map(\.count).max() ?? 0 + let visibleLines = visibleCount.map { Array(lines.prefix($0)) } ?? lines + + return visibleLines.map { line in + let padding = width - line.count + let leftPadding = padding / 2 + let rightPadding = padding - leftPadding + return String(repeating: " ", count: leftPadding) + + line + + String(repeating: " ", count: rightPadding) + } +} From 3b76b40045292d7547a43f15765b4ef7596616f8 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 04:05:15 +0200 Subject: [PATCH 12/18] Test: Render empty ForEach directly - exercise the public collection render path - require an empty buffer without builder substitutes --- Tests/TUIkitTests/ComponentViewTests.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Tests/TUIkitTests/ComponentViewTests.swift b/Tests/TUIkitTests/ComponentViewTests.swift index 56c7a6954..691e08bb3 100644 --- a/Tests/TUIkitTests/ComponentViewTests.swift +++ b/Tests/TUIkitTests/ComponentViewTests.swift @@ -236,12 +236,8 @@ struct ForEachTests { Text(item.name) } - #expect(forEach.data.isEmpty) + let buffer = renderToBuffer(forEach, context: testContext()) - // Also test via ViewArray (which is what @ViewBuilder produces) - let viewArray = ViewArray([]) - let context = testContext() - let buffer = renderToBuffer(viewArray, context: context) #expect(buffer.isEmpty) } } From 0b08ca40e845bb17c9ae4ca69b56840ddb842a1e Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 04:09:08 +0200 Subject: [PATCH 13/18] Test: Cover keyed collection insertion - preserve state and runtime effects when new IDs are inserted - keep focus and selection attached through insert plus reorder - verify inserted task and Observation records clean up --- Tests/TUIkitTests/ItemListHandlerTests.swift | 10 ++-- .../KeyedCollectionRuntimeTests.swift | 47 +++++++++++++------ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/Tests/TUIkitTests/ItemListHandlerTests.swift b/Tests/TUIkitTests/ItemListHandlerTests.swift index 5e78d48c8..55d423c31 100644 --- a/Tests/TUIkitTests/ItemListHandlerTests.swift +++ b/Tests/TUIkitTests/ItemListHandlerTests.swift @@ -351,7 +351,7 @@ struct ItemListHandlerSelectionTests { #expect(handler.isFocused(at: 2) == false) } - @Test("Focus and selection follow item IDs across reorder") + @Test("Focus and selection follow item IDs across insertion and reorder") func focusAndSelectionFollowReorder() { var selectedID: String? = "b" let handler = ItemListHandler( @@ -367,11 +367,11 @@ struct ItemListHandlerSelectionTests { ) handler.focusedIndex = 1 - handler.itemIDs = ["c", "a", "b"] + handler.itemIDs = ["c", "d", "a", "b"] - #expect(handler.focusedIndex == 2) - #expect(handler.isFocused(at: 2)) - #expect(handler.isSelected(at: 2)) + #expect(handler.focusedIndex == 3) + #expect(handler.isFocused(at: 3)) + #expect(handler.isSelected(at: 3)) _ = handler.handleKeyEvent(KeyEvent(key: .enter)) diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift index b67391186..ced31f1ec 100644 --- a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -27,7 +27,7 @@ struct KeyedCollectionRuntimeTests { #expect(actualLines == ["row:0", "row:1"]) } - @Test("ForEach State follows IDs across reorder and removal") + @Test("ForEach State follows IDs across insertion, reorder, and removal") func forEachStateFollowsIDs() { let harness = RuntimeCharacterizationHarness() @@ -38,6 +38,13 @@ struct KeyedCollectionRuntimeTests { } } } + let inserted = harness.render { + VStack { + ForEach([3, 1, 2], id: \.self) { id in + StatefulForEachRow(id: id) + } + } + } let reordered = harness.render { VStack { ForEach([2, 1], id: \.self) { id in @@ -54,12 +61,13 @@ struct KeyedCollectionRuntimeTests { } #expect(initial.ansiStrippedLines == ["1:1", "2:2"]) + #expect(inserted.ansiStrippedLines == ["3:3", "1:1", "2:2"]) #expect(reordered.ansiStrippedLines == ["2:2", "1:1"]) #expect(removed.ansiStrippedLines == ["2:2"]) #expect(harness.storedStateCount == 1) } - @Test("ForEach lifecycle follows IDs across reorder and removal") + @Test("ForEach lifecycle follows IDs across insertion, reorder, and removal") func forEachLifecycleFollowsIDs() { let harness = RuntimeCharacterizationHarness() let trace = harness.trace @@ -73,7 +81,7 @@ struct KeyedCollectionRuntimeTests { } _ = harness.render { VStack { - ForEach([2, 1], id: \.self) { id in + ForEach([3, 2, 1], id: \.self) { id in KeyedLifecycleRow(id: id, trace: trace) } } @@ -81,8 +89,10 @@ struct KeyedCollectionRuntimeTests { #expect(trace.snapshot().filter { $0 == .lifecycle("appear:1") }.count == 1) #expect(trace.snapshot().filter { $0 == .lifecycle("appear:2") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("appear:3") }.count == 1) #expect(trace.snapshot().contains(.lifecycle("disappear:1")) == false) #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) + #expect(trace.snapshot().contains(.lifecycle("disappear:3")) == false) _ = harness.render { VStack { @@ -93,11 +103,12 @@ struct KeyedCollectionRuntimeTests { } #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .lifecycle("disappear:3") }.count == 1) #expect(trace.snapshot().contains(.lifecycle("disappear:2")) == false) #expect(harness.mountedLifecycleCallbackCount == 1) } - @Test("ForEach focus follows IDs across reorder and removal") + @Test("ForEach focus follows IDs across insertion, reorder, and removal") func forEachFocusFollowsIDs() { let harness = RuntimeCharacterizationHarness() let trace = harness.trace @@ -117,7 +128,7 @@ struct KeyedCollectionRuntimeTests { _ = harness.render { HStack { - ForEach([2, 1], id: \.self) { id in + ForEach([3, 2, 1], id: \.self) { id in Button("Item \(id)") { trace.record(.effect("activate:\(id)")) } @@ -127,7 +138,8 @@ struct KeyedCollectionRuntimeTests { #expect(focusedItemTwo != nil) #expect(harness.currentFocusedID == focusedItemTwo) - #expect(Set(harness.storedStateIdentityPaths) == initialStateIdentities) + let insertedStateIdentities = Set(harness.storedStateIdentityPaths) + #expect(initialStateIdentities.isSubset(of: insertedStateIdentities)) #expect(harness.dispatchFocusEvent(KeyEvent(key: .enter))) #expect(trace.snapshot().contains(.effect("activate:2"))) @@ -142,16 +154,17 @@ struct KeyedCollectionRuntimeTests { } #expect(harness.currentFocusedID != focusedItemTwo) - #expect(harness.storedStateCount < initialStateIdentities.count) - #expect(Set(harness.storedStateIdentityPaths).isSubset(of: initialStateIdentities)) + #expect(harness.storedStateCount < insertedStateIdentities.count) + #expect(Set(harness.storedStateIdentityPaths).isSubset(of: insertedStateIdentities)) } - @Test("ForEach tasks follow IDs and cancel removed items", .timeLimit(.minutes(1))) + @Test("ForEach tasks follow inserted IDs and cancel removed items", .timeLimit(.minutes(1))) func forEachTasksFollowIDs() async { let harness = RuntimeCharacterizationHarness() let trace = harness.trace let first = KeyedTaskItem(id: 1) let second = KeyedTaskItem(id: 2) + let third = KeyedTaskItem(id: 3) _ = harness.render { VStack { @@ -165,15 +178,17 @@ struct KeyedCollectionRuntimeTests { _ = harness.render { VStack { - ForEach([second, first]) { item in + ForEach([third, second, first]) { item in KeyedTaskRow(item: item, trace: trace) } } } + await third.started.wait() #expect(trace.snapshot().filter { $0 == .task("start:1") }.count == 1) #expect(trace.snapshot().filter { $0 == .task("start:2") }.count == 1) - #expect(harness.mountedTaskCount == 2) + #expect(trace.snapshot().filter { $0 == .task("start:3") }.count == 1) + #expect(harness.mountedTaskCount == 3) _ = harness.render { VStack { @@ -183,8 +198,10 @@ struct KeyedCollectionRuntimeTests { } } await first.cancelled.wait() + await third.cancelled.wait() #expect(trace.snapshot().filter { $0 == .task("cancel:1") }.count == 1) + #expect(trace.snapshot().filter { $0 == .task("cancel:3") }.count == 1) #expect(trace.snapshot().contains(.task("cancel:2")) == false) #expect(harness.mountedTaskCount == 1) @@ -194,11 +211,12 @@ struct KeyedCollectionRuntimeTests { #expect(harness.mountedTaskCount == 0) } - @Test("ForEach Observation follows IDs and releases removed items") + @Test("ForEach Observation follows inserted IDs and releases removed items") func forEachObservationFollowsIDs() { let harness = RuntimeCharacterizationHarness() let first = ObservedForEachItem(id: 1) let second = ObservedForEachItem(id: 2) + let third = ObservedForEachItem(id: 3) _ = harness.render { VStack { @@ -215,7 +233,7 @@ struct KeyedCollectionRuntimeTests { _ = harness.render { VStack { - ForEach([second, first]) { item in + ForEach([third, second, first]) { item in ObservedForEachRow(item: item) } } @@ -223,7 +241,7 @@ struct KeyedCollectionRuntimeTests { first.model.value = 2 let reorderedInvalidations = harness.consumePendingSubtreeInvalidations() - #expect(harness.observationRegistrationCount == initialRegistrationCount) + #expect(harness.observationRegistrationCount == initialRegistrationCount + 1) #expect(reorderedInvalidations == initialInvalidations) _ = harness.render { @@ -235,6 +253,7 @@ struct KeyedCollectionRuntimeTests { } let remainingRegistrationCount = harness.observationRegistrationCount first.model.value = 3 + third.model.value = 1 #expect(remainingRegistrationCount < initialRegistrationCount) #expect(harness.consumePendingSubtreeInvalidations().isEmpty) From 6ebe4a012afcc0d9565298c89f25b4852365e460 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 22:40:44 +0200 Subject: [PATCH 14/18] Test: Cover nested ForEach rendering through stack builders - outer ForEach over groups, inner ForEach over per-group items - assert all items of all groups render in order inside a VStack - closes the nested-builders gap in issue #12 acceptance criteria --- .../KeyedCollectionRuntimeTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift index ced31f1ec..4c1ecfac7 100644 --- a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -27,6 +27,30 @@ struct KeyedCollectionRuntimeTests { #expect(actualLines == ["row:0", "row:1"]) } + @Test("ForEach nested inside ForEach flattens through stack builders") + func nestedForEachRendersAllElements() { + struct Group: Identifiable { + let id: String + let items: [String] + } + let groups = [ + Group(id: "g1", items: ["a", "b"]), + Group(id: "g2", items: ["c"]) + ] + let harness = RuntimeCharacterizationHarness() + let actualLines = harness.render { + VStack(spacing: 0) { + ForEach(groups) { group in + ForEach(group.items, id: \.self) { item in + Text("\(group.id):\(item)") + } + } + } + }.ansiStrippedLines + + #expect(actualLines == ["g1:a", "g1:b", "g2:c"]) + } + @Test("ForEach State follows IDs across insertion, reorder, and removal") func forEachStateFollowsIDs() { let harness = RuntimeCharacterizationHarness() From 482cc149beec39ef089749a990fa1758c7fcc524 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 22:40:47 +0200 Subject: [PATCH 15/18] Test: Cover Table selection following ForEach IDs - drive Table+ForEach through insert, reorder, delete, and re-add - verify the selection marker stays on the keyed ID across mutations - confirm the binding is not cleared when the selected ID is removed - extend issue #12 selection coverage to the full render path --- .../KeyedCollectionRuntimeTests.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift index 4c1ecfac7..e079e1c65 100644 --- a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -352,6 +352,65 @@ struct KeyedCollectionRuntimeTests { #expect(rendered.contains("Third")) expectDuplicateDiagnostic(in: harness, container: "Table") } + + @Test("Table selection follows ForEach IDs across insert, delete, and reorder") + func tableSelectionFollowsForEachIDs() { + struct Row: Identifiable, Sendable { + let id: String + let label: String + } + + let harness = RuntimeCharacterizationHarness() + var selection: String? + + func renderTable(_ items: [Row]) -> [String] { + harness.render { + Table( + items, + selection: Binding(get: { selection }, set: { selection = $0 }) + ) { + TableColumn("Label", value: \Row.label) + } + }.ansiStrippedLines + } + + // Pre-seed selection on ID "b" and verify the marker sits on its row. + selection = "b" + let initial = renderTable([ + Row(id: "a", label: "Alpha"), + Row(id: "b", label: "Beta") + ]) + #expect(initial.contains { $0.contains("Alpha") }) + #expect(initial.contains { $0.contains("Beta") }) + #expect(initial.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(initial.first(where: { $0.contains("Alpha") })?.contains("●") == false) + + // Insert before, reorder existing: "b" must remain the marked row. + let inserted = renderTable([ + Row(id: "c", label: "Gamma"), + Row(id: "a", label: "Alpha"), + Row(id: "b", label: "Beta") + ]) + #expect(inserted.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(inserted.first(where: { $0.contains("Alpha") })?.contains("●") == false) + #expect(inserted.first(where: { $0.contains("Gamma") })?.contains("●") == false) + #expect(selection == "b") + + // Delete the selected ID: marker disappears, binding is not cleared by the renderer. + let deleted = renderTable([ + Row(id: "a", label: "Alpha") + ]) + #expect(deleted.first(where: { $0.contains("Alpha") })?.contains("●") == false) + #expect(selection == "b") + + // Re-add "b": marker returns on its row. + let restored = renderTable([ + Row(id: "b", label: "Beta"), + Row(id: "a", label: "Alpha") + ]) + #expect(restored.first(where: { $0.contains("Beta") })?.contains("●") == true) + #expect(restored.first(where: { $0.contains("Alpha") })?.contains("●") == false) + } } // MARK: - Helpers From f9ba1f20929756c312681e20d4694b2365764e3d Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 22:53:26 +0200 Subject: [PATCH 16/18] Fix: Record keyed task cancellation off the main actor - the .task closure inherits @MainActor from the render path, so Task.sleep suspends on the main actor and the cancellation notification can be starved under heavy main-actor load from parallel tests in CI, causing a 60s timeout on macOS and Linux - hop to a nonisolated async function so the sleep runs on the global executor and cancellation is processed independently of main-actor scheduling - Task.isCancelled checks the current task's flag, which is the same task even when executing a nonisolated function - use a bounded 1e12 ns sleep (1000 s, well within Int64) instead of UInt64.max to avoid Duration conversion overflow on the Swift 6.0.3 toolchain - TraceRecorder and AsyncSignal are both Sendable and thread-safe, so recording the cancel trace and signalling from the global executor is safe - fixes CI failure on PR #54 for issue #12 --- .../KeyedCollectionRuntimeTests.swift | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift index e079e1c65..d989f905f 100644 --- a/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift +++ b/Tests/TUIkitTests/KeyedCollectionRuntimeTests.swift @@ -484,14 +484,36 @@ private struct KeyedTaskRow: View { trace.record(.task("start:\(item.id)")) item.started.signal() - await withTaskCancellationHandler { - try? await Task.sleep(nanoseconds: UInt64.max) - } onCancel: { - trace.record(.task("cancel:\(item.id)")) - item.cancelled.signal() - } + // The .task closure inherits @MainActor from the render path. + // Task.sleep would suspend on the main actor, and under heavy + // main-actor load from parallel tests in CI the cancellation + // notification can be starved, causing the test to time out. + // Hopping to a nonisolated function moves the sleep to the + // global executor so cancellation is processed independently + // of main-actor scheduling. + await Self.awaitCancellation( + id: item.id, + trace: trace, + signal: item.cancelled + ) } } + + nonisolated private static func awaitCancellation( + id: Int, + trace: TraceRecorder, + signal: AsyncSignal + ) async { + // 1e12 ns (1000 s) is well within Int64 and far exceeds the 60 s + // test time limit, so the sleep only returns via cancellation. + // Task.isCancelled checks the current task's flag, which is the + // same task even when executing a nonisolated function. + try? await Task.sleep(nanoseconds: 1_000_000_000_000) + if Task.isCancelled { + trace.record(.task("cancel:\(id)")) + signal.signal() + } + } } private struct ObservedForEachItem: Identifiable { From baa5b28a47b384a5fe8e18723402694544663f74 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 23:37:25 +0200 Subject: [PATCH 17/18] Fix: Update API compatibility config for ForEach child-provider surface - remove stale ForEach.body Never symbol (body is now some View) - classify new childInfos/childViews symbols on ConditionalView, ViewArray, and ForEach as implementation leaks under issue #35 - add entries to both review-policy.json and compatibility-manifest.json --- .../Configuration/compatibility-manifest.json | 29 ++++++++++++++++--- .../Configuration/review-policy.json | 29 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index c2a5eee8a..3582e80b7 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -344519,23 +344519,44 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV4bodys5NeverOvp" + "symbolID" : "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" + "symbolID" : "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" + "symbolID" : "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" + "symbolID" : "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit7ForEachV10childViews7contextSay0A4View9ChildViewVGAF13RenderContextV_tF" }, + { "classification" : "implementationLeak", "ownerIssue" : "#35", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index 89ce9417e..20014bd57 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -25041,23 +25041,44 @@ { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachV4bodys5NeverOvp" + "symbolID": "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" }, { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" + "symbolID": "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" }, { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" + "symbolID": "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" }, { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" + "symbolID": "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit7ForEachV10childViews7contextSay0A4View9ChildViewVGAF13RenderContextV_tF" }, + { "action": "implementationLeak", "ownerIssue": "#35", From 6197d31785294e7900d51944d916a474ee0033d8 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 00:36:00 +0200 Subject: [PATCH 18/18] Fix: Correct ForEach API compatibility symbols - Fix mangled childViews override: the compiler emits ChildView with the word-substitution form (05ChildG0V), not the fully-spelled 9ChildViewV that was hand-derived from the childInfos symbol. The exact-USR check rejected the stale form as an unknown TUIkit symbol. - Add the ForEach.body implementationLeak override. body changed from Never to some View, but only the old body:Never symbol was removed; the new opaque-result symbol (4bodyQrvp) was left unclassified. - Regenerate compatibility-manifest.json from the tool so it matches the current API snapshots byte-for-byte. --- .../Configuration/compatibility-manifest.json | 38 ++++++++++--------- .../Configuration/review-policy.json | 7 +++- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index 3582e80b7..c75e5b39d 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -337891,6 +337891,16 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView011ConditionalB0O" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -338421,6 +338431,11 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView0B5ArrayV10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" }, + { + "classification" : "implementationLeak", + "ownerIssue" : "#35", + "symbolID" : "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -344519,44 +344534,33 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" - }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" - }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" + "symbolID" : "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView011ConditionalB0O10childInfos7contextSayAA9ChildInfoVGAA13RenderContextV_tF" + "symbolID" : "s:6TUIkit7ForEachV10childViews7contextSay0A4View05ChildG0VGAF13RenderContextV_tF" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView011ConditionalB0O10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + "symbolID" : "s:6TUIkit7ForEachV4bodyQrvp" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView0B5ArrayV10childViews7contextSayAA05ChildB0VGAA13RenderContextV_tF" + "symbolID" : "s:6TUIkit7ForEachVAA7Element_2IDQZRs_s12IdentifiableADRpzrlE_7contentACyxq_q0_Gx_q0_AHctcfc" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV10childInfos7contextSay0A4View9ChildInfoVGAF13RenderContextV_tF" + "symbolID" : "s:6TUIkit7ForEachVAASnySiGRszSiRs_rlE_7contentACyADSiq0_GAD_q0_Sictcfc" }, { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit7ForEachV10childViews7contextSay0A4View9ChildViewVGAF13RenderContextV_tF" + "symbolID" : "s:6TUIkit7ForEachV_2id7contentACyxq_q0_Gx_s7KeyPathCy7ElementQzq_Gq0_AJctcfc" }, - { "classification" : "implementationLeak", "ownerIssue" : "#35", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index 20014bd57..d63a1e38c 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -25038,6 +25038,11 @@ "ownerIssue": "#35", "symbolID": "s:6TUIkit7ForEachV" }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit7ForEachV4bodyQrvp" + }, { "action": "implementationLeak", "ownerIssue": "#35", @@ -25076,7 +25081,7 @@ { "action": "implementationLeak", "ownerIssue": "#35", - "symbolID": "s:6TUIkit7ForEachV10childViews7contextSay0A4View9ChildViewVGAF13RenderContextV_tF" + "symbolID": "s:6TUIkit7ForEachV10childViews7contextSay0A4View05ChildG0VGAF13RenderContextV_tF" }, {