From 22947dad3c4885973624959e8ffdb738eaad69f6 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 21:43:51 +0200 Subject: [PATCH 1/5] Test: Specify pending effect commit semantics - Pin lifetime-effect leaks: superseded passes fire onAppear and mount tasks, onAppear runs before writeFrame, measure-pass observation trackings survive, GC keeps ghost state from discarded passes - Guard the stable case: a subtree present in every pass appears exactly once across a correction frame - Extract shared frame fixtures (GrowableHeaderModel, GrowingHeader, HeightGate) into test support - Mark desired behavior withKnownIssue until the pending diff lands (#57) --- .../PendingEffectCommitTests.swift | 271 ++++++++++++++++++ .../RenderPassCollectorTests.swift | 46 +-- Tests/TUIkitTests/Support/FrameFixtures.swift | 58 ++++ 3 files changed, 332 insertions(+), 43 deletions(-) create mode 100644 Tests/TUIkitTests/PendingEffectCommitTests.swift create mode 100644 Tests/TUIkitTests/Support/FrameFixtures.swift diff --git a/Tests/TUIkitTests/PendingEffectCommitTests.swift b/Tests/TUIkitTests/PendingEffectCommitTests.swift new file mode 100644 index 00000000..44c1e1fc --- /dev/null +++ b/Tests/TUIkitTests/PendingEffectCommitTests.swift @@ -0,0 +1,271 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// PendingEffectCommitTests.swift +// +// License: MIT + +import Observation +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +/// Specifies the pending-diff commit semantics of issue #57: effects that +/// OUTLIVE the frame (`onAppear`/`onDisappear` actions, `.task` mounts, +/// observation trackings, GC liveness) are only recorded during traversal +/// and applied exactly once at frame commit, diffed against persistent +/// runtime state. Discarded passes contribute nothing. +/// +/// Tests assert the DESIRED behavior and carry `withKnownIssue` markers +/// until the pending diff lands (#57 Tasks 2-4); then the markers drop. +@MainActor +@Suite("Pending Effect Commit", .serialized) +struct PendingEffectCommitTests { + + @Test("A superseded pass fires no onAppear and mounts no task") + func supersededPassLeavesNoLifetimeEffects() { + let harness = FrameHarness(app: SupersededEffectsApp()) + + // Frame 1: the gated subtree is visible (content height 22 ≥ 21), + // so its effects legitimately belong to the committed tree. + harness.renderFrame() + harness.app.trace.reset() + + // Frame 2 (correction): the main pass at height 22 still contains + // the gated subtree with a FRESH identity (the gate re-keys its + // child by frame), but the corrected final tree at height 20 does + // not. Its appear action must never fire. + harness.app.model.lineCount = 3 + harness.app.gateGeneration.value += 1 + harness.renderFrame() + + withKnownIssue("Issue #57: lifetime effects apply during traversal") { + #expect(!harness.app.trace.snapshot().contains("appear:superseded")) + #expect(harness.tuiContext.lifecycle.taskCount == 0) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("onAppear fires after the frame is written to the terminal") + func onAppearFiresAfterWriteFrame() { + let terminalWrites = TraceRecorder() + let harness = FrameHarness(app: AppearTimingApp()) + harness.app.hook.callback = { [weak terminal = harness.terminal] in + terminalWrites.record(terminal?.writtenOutput.count ?? -1) + } + + harness.renderFrame() + + // The appear action must observe a non-empty terminal output: + // commit order is collectors → writeFrame → lifetime effects. + withKnownIssue("Issue #57: onAppear fires during traversal, before writeFrame") { + #expect(terminalWrites.snapshot().allSatisfy { $0 > 0 }) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("Observation trackings from the measure pass do not survive the frame") + func measurePassObservationDoesNotSurvive() { + let harness = FrameHarness(app: MeasureOnlyObservationApp()) + + harness.renderFrame() + harness.tuiContext.appState.didRender() + + // Mutating the model that ONLY the measure-pass tree observed must + // not invalidate the committed frame. + harness.app.model.value += 1 + + withKnownIssue("Issue #57: measure-pass observation trackings stay current") { + #expect(harness.tuiContext.appState.needsRender == false) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("State storage keeps records for the committed tree only") + func gcRunsOnCommittedTreeOnly() { + let harness = FrameHarness(app: MeasureOnlyStateApp()) + + harness.renderFrame() + + let ghostPaths = harness.tuiContext.stateStorage.storedIdentities + .map(\.path) + .filter { $0.contains("MeasureOnlyStatefulLeaf") } + + withKnownIssue("Issue #57: GC liveness is the union of all passes") { + #expect(ghostPaths.isEmpty) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("A stable subtree fires appear exactly once across a correction frame") + func stableSubtreeAppearsOnceAcrossCorrection() { + let harness = FrameHarness(app: StableAcrossCorrectionApp()) + + harness.renderFrame() + harness.app.model.lineCount = 3 + harness.renderFrame() + + let appearCount = harness.app.trace.snapshot().filter { $0 == "appear:stable" }.count + #expect(appearCount == 1) + #expect(harness.tuiContext.lifecycle.taskCount == 1) + } +} + +// MARK: - Fixtures + +/// Reference cell so fixtures can re-key subtrees between frames. +@MainActor +private final class GenerationCell { + var value = 0 +} + +/// Gated subtree whose lifecycle identity changes each generation, so its +/// appear record is fresh in every frame that contains it. +private struct SupersededEffectsApp: App { + let model = GrowableHeaderModel() + let gateGeneration = GenerationCell() + let trace = TraceRecorder() + + init() {} + + var body: some Scene { + WindowGroup { + HeightGate( + threshold: 21, + content: Text("ghost \(gateGeneration.value)") + .onAppear { trace.record("appear:superseded") } + .task {} + .id(generation: gateGeneration.value) + ) + .appHeader { GrowingHeader(model: model) } + } + } +} + +extension View { + /// Re-bases the view under a generation-keyed branch identity so + /// lifecycle slots differ between generations. + fileprivate func id(generation: Int) -> some View { + GenerationBranch(generation: generation, content: self) + } +} + +private struct GenerationBranch: View, Renderable { + let generation: Int + let content: Content + + var body: Never { + fatalError("GenerationBranch renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + TUIkit.renderToBuffer(content, context: context.withBranchIdentity("gen-\(generation)")) + } +} + +/// Mutable callback slot shared between a test and its app fixture. +@MainActor +private final class CallbackCell { + var callback: (() -> Void)? +} + +/// Records the terminal write count at the moment its appear action runs. +private struct AppearTimingApp: App { + let hook = CallbackCell() + + init() {} + + var body: some Scene { + WindowGroup { + Text("timing").onAppear { hook.callback?() } + } + } +} + +@Observable +private final class ObservedModel { + var value = 0 +} + +/// Observes a model in a composite body that exists ONLY in the measure +/// pass (phase-gated). +private struct MeasureOnlyObservationApp: App { + let model = ObservedModel() + + init() {} + + var body: some Scene { + WindowGroup { + MeasurePhaseGate(content: ObservingLeaf(model: model)) + } + } +} + +/// Composite view whose body reads the observable model. +private struct ObservingLeaf: View { + let model: ObservedModel + + var body: some View { + Text("value: \(model.value)") + } +} + +/// Renders its content only during the measurement phase. +private struct MeasurePhaseGate: View, Renderable { + let content: Content + + var body: Never { + fatalError("MeasurePhaseGate renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + guard context.phase == .measure else { + return FrameBuffer(text: "committed body") + } + return TUIkit.renderToBuffer(content, context: context) + } +} + +/// Holds @State in a leaf that exists ONLY in the measure pass. +private struct MeasureOnlyStateApp: App { + init() {} + + var body: some Scene { + WindowGroup { + MeasurePhaseGate(content: MeasureOnlyStatefulLeaf()) + } + } +} + +private struct MeasureOnlyStatefulLeaf: View { + @State private var counter = 7 + + var body: some View { + Text("counter: \(counter)") + } +} + +/// A subtree present in every pass of every frame: its lifetime effects +/// must behave identically for single-pass and correction frames. +private struct StableAcrossCorrectionApp: App { + let model = GrowableHeaderModel() + let trace = TraceRecorder() + + init() {} + + var body: some Scene { + WindowGroup { + Text("stable") + .onAppear { trace.record("appear:stable") } + .task {} + .appHeader { GrowingHeader(model: model) } + } + } +} diff --git a/Tests/TUIkitTests/RenderPassCollectorTests.swift b/Tests/TUIkitTests/RenderPassCollectorTests.swift index 3b1b8d82..0b2a7d91 100644 --- a/Tests/TUIkitTests/RenderPassCollectorTests.swift +++ b/Tests/TUIkitTests/RenderPassCollectorTests.swift @@ -91,50 +91,10 @@ struct RenderPassCollectorTests { } } -// MARK: - Shared Fixtures - -/// Mutable header size shared between the test and the app fixtures. -@MainActor -final class GrowableHeaderModel { - var lineCount = 1 -} - -/// Renders `content` only when the available height is at least `threshold`. -/// -/// Records nothing and has no effects of its own; used to make a subtree -/// exist in one pass of a frame but not in another (measure vs. main, or -/// main vs. correction). -private struct HeightGate: View, Renderable { - let threshold: Int - let content: Content - - var body: Never { - fatalError("HeightGate renders via Renderable") - } - - func renderToBuffer(context: RenderContext) -> FrameBuffer { - guard context.availableHeight >= threshold else { - return FrameBuffer(text: "below-gate") - } - return TUIkit.renderToBuffer(content, context: context) - } -} - -/// Grows the app header via `GrowableHeaderModel` so frame 2 runs through -/// the header-correction pass (estimate 2, actual 4 → content 22 → 20). -private struct GrowingHeader: View { - let model: GrowableHeaderModel - - var body: some View { - VStack { - ForEach(Array(0..: View, Renderable { + let threshold: Int + let content: Content + + var body: Never { + fatalError("HeightGate renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + guard context.availableHeight >= threshold else { + return FrameBuffer(text: "below-gate") + } + return TUIkit.renderToBuffer(content, context: context) + } +} From 3214b0fd5c83ea53c24d746925869bddf4e3b4b8 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 21:47:52 +0200 Subject: [PATCH 2/5] Fix: Commit lifecycle and task effects from the final tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PendingFrameEffects: an ordered per-pass command log for lifetime effects plus GC liveness sets, replayed once at frame commit after terminal output - Record onAppear/onDisappear/task in the pending log during RenderLoop passes; the live path (ViewRenderer, harnesses) keeps immediate semantics - Replay only the final pass's records — a task from a discarded pass is never even started, and onAppear observes the written frame - Promote the superseded-pass and appear-timing tests to hard expectations --- Sources/TUIkit/App/RenderLoop.swift | 8 ++ Sources/TUIkit/App/RenderPassCollectors.swift | 6 + .../TUIkit/Modifiers/LifecycleModifier.swift | 57 ++++++-- .../Rendering/PendingFrameEffects.swift | 123 ++++++++++++++++++ .../PendingEffectCommitTests.swift | 18 +-- 5 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 Sources/TUIkitView/Rendering/PendingFrameEffects.swift diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index c1402a2d..065b8ddc 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -305,6 +305,13 @@ extension RenderLoop { headerHeight: appHeader.height ) + // COMMIT (step 6c): replay the final pass's lifetime effects + // (onAppear, onDisappear registration, task mounts, deferred + // actions) against the live managers — after terminal output, in + // traversal order, exactly once. Records of discarded passes were + // dropped with their collectors and never run. + collectors.pendingEffects.commitDeferredEffects() + endRenderPass() } @@ -343,6 +350,7 @@ extension RenderLoop { environment.preferenceStorage = collectors.preferences environment.statusBar = collectors.statusBar environment.appHeader = collectors.appHeader + environment.pendingFrameEffects = collectors.pendingEffects return environment } } diff --git a/Sources/TUIkit/App/RenderPassCollectors.swift b/Sources/TUIkit/App/RenderPassCollectors.swift index f8ba73cb..ebc6f9db 100644 --- a/Sources/TUIkit/App/RenderPassCollectors.swift +++ b/Sources/TUIkit/App/RenderPassCollectors.swift @@ -60,6 +60,12 @@ struct RenderPassCollectors { /// Scratch sink for the app-header buffer. let appHeader = AppHeaderState() + /// Records for effects that OUTLIVE the frame (lifecycle, tasks, + /// deferred actions, GC liveness). Collected per pass like the scratch + /// sinks above, but committed by replay after terminal output instead + /// of by adoption — see ``PendingFrameEffects``. + let pendingEffects = PendingFrameEffects() + /// Creates fresh collectors for one render pass. /// /// - Parameter appState: The runtime's render-state sink; required by diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index 231bccaa..cc641395 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -22,11 +22,18 @@ struct OnAppearModifier: View { extension OnAppearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.appear") + // Lifetime effect (outlives the frame): recorded for the frame + // commit; the live path (no pending records) applies it directly. if context.phase == .render { - _ = context.environment.lifecycle!.recordAppear( - identity: scopedContext.identity, - action: action - ) + let lifecycle = context.environment.lifecycle! + let identity = scopedContext.identity + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.recordEffect { [action] in + _ = lifecycle.recordAppear(identity: identity, action: action) + } + } else { + _ = lifecycle.recordAppear(identity: identity, action: action) + } } return TUIkit.renderToBuffer(content, context: scopedContext) @@ -51,10 +58,20 @@ struct OnDisappearModifier: View { extension OnDisappearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.disappear") + // Lifetime effect (outlives the frame): recorded for the frame + // commit; the live path (no pending records) applies it directly. if context.phase == .render { let lifecycle = context.environment.lifecycle! - lifecycle.registerDisappear(identity: scopedContext.identity, action: action) - _ = lifecycle.recordAppear(identity: scopedContext.identity, action: {}) + let identity = scopedContext.identity + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.recordEffect { [action] in + lifecycle.registerDisappear(identity: identity, action: action) + _ = lifecycle.recordAppear(identity: identity, action: {}) + } + } else { + lifecycle.registerDisappear(identity: identity, action: action) + _ = lifecycle.recordAppear(identity: identity, action: {}) + } } return TUIkit.renderToBuffer(content, context: scopedContext) @@ -84,13 +101,29 @@ struct TaskModifier: View { extension TaskModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.task") + // Lifetime effect (outlives the frame): recorded for the frame + // commit; the live path (no pending records) applies it directly. + // A task from a discarded pass is therefore never even started. if context.phase == .render { - context.environment.lifecycle!.updateTask( - identity: scopedContext.identity, - id: MountedTaskID.value, - priority: priority, - operation: task - ) + let lifecycle = context.environment.lifecycle! + let identity = scopedContext.identity + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.recordEffect { [task, priority] in + lifecycle.updateTask( + identity: identity, + id: MountedTaskID.value, + priority: priority, + operation: task + ) + } + } else { + lifecycle.updateTask( + identity: identity, + id: MountedTaskID.value, + priority: priority, + operation: task + ) + } } return TUIkit.renderToBuffer(content, context: scopedContext) diff --git a/Sources/TUIkitView/Rendering/PendingFrameEffects.swift b/Sources/TUIkitView/Rendering/PendingFrameEffects.swift new file mode 100644 index 00000000..99574543 --- /dev/null +++ b/Sources/TUIkitView/Rendering/PendingFrameEffects.swift @@ -0,0 +1,123 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// PendingFrameEffects.swift +// +// Created by LAYERED.work +// License: MIT + +import TUIkitCore + +// MARK: - Pending Frame Effects + +/// Per-pass records for effects that **outlive** the frame. +/// +/// This is the pending-diff half of the effect classification rule (see +/// ``RenderPhase``): +/// +/// > Does the effect outlive the frame? **Yes → pending diff (this type).** +/// > No → pass collector (`RenderPassCollectors`). +/// +/// Lifetime effects — `onAppear`/`onDisappear` actions, `.task` mounts, +/// `onChange`/`onPreferenceChange` actions, and GC liveness — must derive +/// from the frame's **committed** tree, exactly once. A traversal therefore +/// never applies them directly; it records them here, and the frame commit +/// replays only the FINAL pass's records against the live runtime. +/// Records of discarded passes (measurement, superseded main pass) are +/// dropped with their instance — nothing to roll back, because nothing live +/// was touched. +/// +/// ## Two record kinds +/// +/// - **Deferred effects** (``recordEffect(_:)``): an ordered command log of +/// closures replayed in traversal order by ``commitDeferredEffects()`` +/// after the frame is written to the terminal. Effect sites capture their +/// own manager calls (e.g. `lifecycle.recordAppear`), so this type needs +/// no knowledge of the managers involved and the managers keep their +/// existing diff semantics (appear-once, task restart IDs, disappear on +/// removal). +/// - **Liveness sets** (``markActive(_:)``, ``markSubtreeActive(_:)``): +/// the identities the final tree keeps alive. The frame commit hands them +/// to the state/cache/observation GC, so records that only a discarded +/// pass touched are collected at the end of the frame. +/// +/// ## Live path +/// +/// Rendering outside `RenderLoop` (e.g. `ViewRenderer`, test harnesses) +/// runs without a `PendingFrameEffects` instance in the environment; effect +/// sites fall back to their immediate live semantics there. See +/// ``TUIkitCore/EnvironmentValues/pendingFrameEffects``. +@MainActor +package final class PendingFrameEffects { + /// Identities the current pass keeps alive for identity-based GC. + package private(set) var activeIdentities: Set = [] + + /// Roots of cached subtrees whose descendants stay alive without being + /// traversed (see `EquatableView` cache hits). + package private(set) var activeSubtreeRoots: Set = [] + + /// Ordered command log of lifetime effects, replayed at frame commit. + private var deferredEffects: [() -> Void] = [] + + /// Creates an empty record set for one render pass. + package init() {} + + /// Number of recorded deferred effects, for tests and diagnostics. + package var deferredEffectCount: Int { + deferredEffects.count + } + + /// Marks an identity as alive in the final tree. + /// + /// - Parameter identity: The structural identity to keep. + package func markActive(_ identity: ViewIdentity) { + activeIdentities.insert(identity) + } + + /// Marks a cached subtree root whose descendants stay alive without + /// traversal. + /// + /// - Parameter root: The subtree root identity. + package func markSubtreeActive(_ root: ViewIdentity) { + activeSubtreeRoots.insert(root) + } + + /// Appends a lifetime effect to the command log. + /// + /// The closure runs at frame commit (after terminal output), in + /// traversal order, exactly once — and only if this pass becomes the + /// frame's final pass. + /// + /// - Parameter effect: The deferred manager call. + package func recordEffect(_ effect: @escaping () -> Void) { + deferredEffects.append(effect) + } + + /// Replays the recorded lifetime effects in traversal order. + /// + /// Called exactly once per frame by the commit step, after the frame + /// buffer has been written to the terminal. + package func commitDeferredEffects() { + let effects = deferredEffects + deferredEffects.removeAll() + for effect in effects { + effect() + } + } +} + +// MARK: - Environment Key + +/// EnvironmentKey for the current pass's pending effect records. +private struct PendingFrameEffectsKey: EnvironmentKey { + static let defaultValue: PendingFrameEffects? = nil +} + +extension EnvironmentValues { + /// The pending effect records of the current render pass. + /// + /// `nil` outside `RenderLoop` frames (standalone `ViewRenderer`, test + /// harnesses) — effect sites then apply their live semantics directly. + package var pendingFrameEffects: PendingFrameEffects? { + get { self[PendingFrameEffectsKey.self] } + set { self[PendingFrameEffectsKey.self] = newValue } + } +} diff --git a/Tests/TUIkitTests/PendingEffectCommitTests.swift b/Tests/TUIkitTests/PendingEffectCommitTests.swift index 44c1e1fc..00afb3da 100644 --- a/Tests/TUIkitTests/PendingEffectCommitTests.swift +++ b/Tests/TUIkitTests/PendingEffectCommitTests.swift @@ -38,13 +38,8 @@ struct PendingEffectCommitTests { harness.app.gateGeneration.value += 1 harness.renderFrame() - withKnownIssue("Issue #57: lifetime effects apply during traversal") { - #expect(!harness.app.trace.snapshot().contains("appear:superseded")) - #expect(harness.tuiContext.lifecycle.taskCount == 0) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(!harness.app.trace.snapshot().contains("appear:superseded")) + #expect(harness.tuiContext.lifecycle.taskCount == 0) } @Test("onAppear fires after the frame is written to the terminal") @@ -59,12 +54,9 @@ struct PendingEffectCommitTests { // The appear action must observe a non-empty terminal output: // commit order is collectors → writeFrame → lifetime effects. - withKnownIssue("Issue #57: onAppear fires during traversal, before writeFrame") { - #expect(terminalWrites.snapshot().allSatisfy { $0 > 0 }) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + let writeCounts = terminalWrites.snapshot() + #expect(!writeCounts.isEmpty) + #expect(writeCounts.allSatisfy { $0 > 0 }) } @Test("Observation trackings from the measure pass do not survive the frame") From bbf6902a82c470fbe653dee592957c660b3b1886 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 21:59:36 +0200 Subject: [PATCH 3/5] Fix: Defer onChange and preference actions to commit - Record onChange actions and tracked-value writes in the pending log; per-pass index claims keep tracked values stable across correction passes - Rework onPreferenceChange to SwiftUI semantics: the action fires when the subtree's reduced value changes against the last committed frame (once on first appearance), at frame commit - Delete the now-unused PreferenceStorage callback machinery (no deprecations) - Promote the onChange characterization to a hard expectation; specify change-driven preference firing across correction frames --- Sources/TUIkit/Environment/Preferences.swift | 68 +++++++++++++++---- .../TUIkit/Modifiers/OnChangeModifier.swift | 47 ++++++++++--- .../Environment/PreferenceKey.swift | 55 +++------------ .../Rendering/PendingFrameEffects.swift | 34 +++++++++- .../PendingEffectCommitTests.swift | 49 +++++++++++++ .../TUIkitTests/PreferenceStorageTests.swift | 30 ++------ .../RenderPassCollectorTests.swift | 28 -------- .../RenderPhaseCharacterizationTests.swift | 7 +- 8 files changed, 186 insertions(+), 132 deletions(-) diff --git a/Sources/TUIkit/Environment/Preferences.swift b/Sources/TUIkit/Environment/Preferences.swift index e74c444d..89267c90 100644 --- a/Sources/TUIkit/Environment/Preferences.swift +++ b/Sources/TUIkit/Environment/Preferences.swift @@ -51,24 +51,68 @@ where K.Value: Equatable { extension OnPreferenceChangeModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let prefs = context.environment.preferenceStorage! + let storage = context.environment.stateStorage! + let pendingEffects = context.environment.pendingFrameEffects - // Register callback for preference changes - prefs.onPreferenceChange(K.self, callback: action) - - // Push a new preference context + // Collect the subtree's preferences into a fresh scope. prefs.push() - - // Render content let buffer = TUIkit.renderToBuffer(content, context: context) - - // Pop and get collected preferences - let preferences = prefs.pop() - - // Trigger action with current value - action(preferences[K.self]) + let subtreeValue = prefs.pop()[K.self] + + // SwiftUI semantics: the action fires when the subtree's reduced + // value CHANGED against the last committed frame (and once on first + // appearance). Firing and updating the tracked value are lifetime + // effects — recorded for the frame commit so a discarded pass never + // fires and never corrupts the tracked value. The tracking slot is + // scoped per preference key type, so stacked onPreferenceChange + // modifiers on one view do not collide. + let trackingIdentity = context.identity + .scoped("preference.\(String(reflecting: K.self))") + let key = StateStorage.StateKey(identity: trackingIdentity, propertyIndex: 0) + let previousValue: K.Value? = storage.trackedValue(for: key) + + if let pendingEffects { + pendingEffects.recordEffect { [action, previousValue, subtreeValue, storage, key, trackingIdentity] in + Self.commitChange( + previousValue: previousValue, + subtreeValue: subtreeValue, + action: action, + storage: storage, + key: key, + trackingIdentity: trackingIdentity + ) + } + } else { + // Live path (ViewRenderer, harnesses): immediate semantics. + Self.commitChange( + previousValue: previousValue, + subtreeValue: subtreeValue, + action: action, + storage: storage, + key: key, + trackingIdentity: trackingIdentity + ) + } return buffer } + + /// Fires the change action when the subtree value changed (or first + /// appeared) and records the new value for the next frame's comparison. + private static func commitChange( + previousValue: K.Value?, + subtreeValue: K.Value, + action: (K.Value) -> Void, + storage: StateStorage, + key: StateStorage.StateKey, + trackingIdentity: ViewIdentity + ) { + if previousValue == nil || previousValue != subtreeValue { + action(subtreeValue) + } + storage.setTrackedValue(subtreeValue, for: key) + storage.markActive(trackingIdentity) + } } // MARK: - Common Preference Keys diff --git a/Sources/TUIkit/Modifiers/OnChangeModifier.swift b/Sources/TUIkit/Modifiers/OnChangeModifier.swift index 4652d162..2da6b13d 100644 --- a/Sources/TUIkit/Modifiers/OnChangeModifier.swift +++ b/Sources/TUIkit/Modifiers/OnChangeModifier.swift @@ -38,26 +38,51 @@ struct OnChangeModifier: View { extension OnChangeModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let storage = context.environment.stateStorage! + let pendingEffects = context.environment.pendingFrameEffects - // Claim unique index for this onChange at this identity - let index = storage.nextOnChangeIndex(for: context.identity) + // Claim a unique index for this onChange at this identity. Inside a + // RenderLoop pass the counter is pass-scoped, so main and correction + // pass claim identical indices and tracked values stay stable. + let index = pendingEffects?.nextOnChangeIndex(for: context.identity) + ?? storage.nextOnChangeIndex(for: context.identity) let key = StateStorage.StateKey(identity: context.identity, propertyIndex: index) - // Compare with previous value - if let oldValue: V = storage.trackedValue(for: key) { - if oldValue != value { - action(oldValue, value) + // Compare against the last COMMITTED frame's value. Writing the new + // value back is a lifetime effect: it must happen exactly once at + // frame commit, or a correction pass would see its own sibling + // pass's value instead of the previous frame's. + let oldValue: V? = storage.trackedValue(for: key) + if let pendingEffects { + pendingEffects.recordEffect { [value, initial, action] in + fireIfChanged(oldValue: oldValue, newValue: value, initial: initial, action: action) + storage.setTrackedValue(value, for: key) } - } else if initial { - action(value, value) + } else { + // Live path (ViewRenderer, harnesses): immediate semantics. + fireIfChanged(oldValue: oldValue, newValue: value, initial: initial, action: action) + storage.setTrackedValue(value, for: key) } - // Store current value for next render pass - storage.setTrackedValue(value, for: key) - // Keep tracked values alive through GC storage.markActive(context.identity) return TUIkitView.renderToBuffer(content, context: context) } } + +/// Runs an `onChange` action when the tracked value changed, or on the +/// first observation when `initial` is set. +private func fireIfChanged( + oldValue: V?, + newValue: V, + initial: Bool, + action: (V, V) -> Void +) { + if let oldValue { + if oldValue != newValue { + action(oldValue, newValue) + } + } else if initial { + action(newValue, newValue) + } +} diff --git a/Sources/TUIkitCore/Environment/PreferenceKey.swift b/Sources/TUIkitCore/Environment/PreferenceKey.swift index 9f1c76d6..bef6bc1d 100644 --- a/Sources/TUIkitCore/Environment/PreferenceKey.swift +++ b/Sources/TUIkitCore/Environment/PreferenceKey.swift @@ -98,9 +98,6 @@ public final class PreferenceStorage: @unchecked Sendable { /// Stack of preference values for nested rendering. private var stack: [PreferenceValues] = [PreferenceValues()] - /// Callbacks registered to receive preference changes. - private var callbacks: [ObjectIdentifier: [(Any) -> Void]] = [:] - /// Creates a new preference storage. public init() {} @@ -146,41 +143,13 @@ public extension PreferenceStorage { var currentValues = current K.reduce(value: ¤tValues[key]) { value } current = currentValues - - // Notify callbacks - let keyId = ObjectIdentifier(key) - if let keyCallbacks = callbacks[keyId] { - for callback in keyCallbacks { - callback(value) - } - } - } - - /// Registers a callback for preference changes. - func onPreferenceChange( - _ key: K.Type, - callback: @escaping (K.Value) -> Void - ) { - let keyId = ObjectIdentifier(key) - let wrappedCallback: (Any) -> Void = { value in - if let typedValue = value as? K.Value { - callback(typedValue) - } - } - - if callbacks[keyId] == nil { - callbacks[keyId] = [] - } - callbacks[keyId]?.append(wrappedCallback) } /// Prepares preference storage for a new render pass. /// - /// Clears all accumulated callbacks and resets the value stack - /// to a single empty context. Called at the start of each frame - /// by `RenderLoop.render()` to prevent callback accumulation. + /// Resets the value stack to a single empty context. Called at the + /// start of each frame by `RenderLoop.render()`. func beginRenderPass() { - callbacks.removeAll() stack = [PreferenceValues()] } @@ -189,30 +158,22 @@ public extension PreferenceStorage { /// Called once during app shutdown by `TUIContext.reset()`. func reset() { stack = [PreferenceValues()] - callbacks.removeAll() } } // MARK: - Per-Pass Adoption package extension PreferenceStorage { - /// Number of registered change callbacks for tests and diagnostics. - var callbackCount: Int { - callbacks.values.reduce(0) { $0 + $1.count } - } - - /// Replaces the collected values and change callbacks with the ones from - /// a per-pass scratch storage. + /// Replaces the collected values with the ones from a per-pass scratch + /// storage. /// - /// Preference values and their change callbacks do not outlive the - /// frame: the tree re-declares them on every traversal. At frame commit - /// the live storage therefore adopts the FINAL pass's state wholesale; - /// storages of discarded passes are dropped, so their callbacks can - /// never accumulate on the live instance. + /// Preference values do not outlive the frame: the tree re-declares + /// them on every traversal. At frame commit the live storage therefore + /// adopts the FINAL pass's values wholesale; storages of discarded + /// passes are dropped with their pass. /// /// - Parameter collector: The scratch storage of the frame's final pass. func adopt(from collector: PreferenceStorage) { stack = collector.stack - callbacks = collector.callbacks } } diff --git a/Sources/TUIkitView/Rendering/PendingFrameEffects.swift b/Sources/TUIkitView/Rendering/PendingFrameEffects.swift index 99574543..f0414ff7 100644 --- a/Sources/TUIkitView/Rendering/PendingFrameEffects.swift +++ b/Sources/TUIkitView/Rendering/PendingFrameEffects.swift @@ -45,8 +45,14 @@ import TUIkitCore /// runs without a `PendingFrameEffects` instance in the environment; effect /// sites fall back to their immediate live semantics there. See /// ``TUIkitCore/EnvironmentValues/pendingFrameEffects``. -@MainActor -package final class PendingFrameEffects { +/// +/// ## Thread Safety +/// +/// Like `StateStorage` and `PreferenceStorage`, this type is accessed only +/// from the main thread (TUIkit's single-threaded render loop): records are +/// written during traversal and replayed at commit, both on the main actor. +/// No locking is required. +package final class PendingFrameEffects: @unchecked Sendable { /// Identities the current pass keeps alive for identity-based GC. package private(set) var activeIdentities: Set = [] @@ -55,8 +61,18 @@ package final class PendingFrameEffects { package private(set) var activeSubtreeRoots: Set = [] /// Ordered command log of lifetime effects, replayed at frame commit. + /// + /// Plain (non-Sendable) closures: recording and replay both happen on + /// the main actor, so captures never cross an isolation boundary. private var deferredEffects: [() -> Void] = [] + /// Per-identity counters disambiguating chained `onChange` modifiers. + /// + /// Scoped to this pass (unlike the frame-scoped fallback in + /// `StateStorage`), so a correction pass claims the same indices as the + /// superseded main pass and tracked values stay stable across passes. + private var onChangeCounters: [ViewIdentity: Int] = [:] + /// Creates an empty record set for one render pass. package init() {} @@ -80,13 +96,25 @@ package final class PendingFrameEffects { activeSubtreeRoots.insert(root) } + /// Claims the next `onChange` slot index for an identity in this pass. + /// + /// - Parameter identity: The view identity requesting an index. + /// - Returns: The next available index (starting at 0 per pass). + package func nextOnChangeIndex(for identity: ViewIdentity) -> Int { + let index = onChangeCounters[identity, default: 0] + onChangeCounters[identity] = index + 1 + return index + } + /// Appends a lifetime effect to the command log. /// /// The closure runs at frame commit (after terminal output), in /// traversal order, exactly once — and only if this pass becomes the /// frame's final pass. /// - /// - Parameter effect: The deferred manager call. + /// - Parameter effect: The deferred manager call. Callers capture their + /// values explicitly (capture list) so non-Sendable generics copy into + /// the record without region-crossing diagnostics. package func recordEffect(_ effect: @escaping () -> Void) { deferredEffects.append(effect) } diff --git a/Tests/TUIkitTests/PendingEffectCommitTests.swift b/Tests/TUIkitTests/PendingEffectCommitTests.swift index 00afb3da..b343f763 100644 --- a/Tests/TUIkitTests/PendingEffectCommitTests.swift +++ b/Tests/TUIkitTests/PendingEffectCommitTests.swift @@ -96,6 +96,27 @@ struct PendingEffectCommitTests { } } + @Test("onPreferenceChange fires once per change, even across a correction frame") + func onPreferenceChangeFiresOncePerChange() { + let harness = FrameHarness(app: PreferenceActionApp()) + + // First frame: the value appears → exactly one initial fire. + harness.renderFrame() + #expect(harness.app.trace.snapshot() == ["preference:one"]) + + // Unchanged value → no fire, regardless of re-rendering. + harness.app.trace.reset() + harness.renderFrame() + #expect(harness.app.trace.snapshot() == []) + + // Changed value in a frame that traverses twice (main + correction): + // the action fires exactly once, for the committed tree. + harness.app.title.value = "two" + harness.app.model.lineCount = 3 + harness.renderFrame() + #expect(harness.app.trace.snapshot() == ["preference:two"]) + } + @Test("A stable subtree fires appear exactly once across a correction frame") func stableSubtreeAppearsOnceAcrossCorrection() { let harness = FrameHarness(app: StableAcrossCorrectionApp()) @@ -244,6 +265,34 @@ private struct MeasureOnlyStatefulLeaf: View { } } +/// Mutable string cell shared between a test and its app fixture. +@MainActor +private final class StringCell { + var value = "one" +} + +/// Declares a preference and observes it; the change action must fire once +/// per VALUE CHANGE (SwiftUI semantics), regardless of how many passes a +/// frame needed. +private struct PreferenceActionApp: App { + let model = GrowableHeaderModel() + let title = StringCell() + let trace = TraceRecorder() + + init() {} + + var body: some Scene { + WindowGroup { + Text("body") + .preference(key: NavigationTitleKey.self, value: title.value) + .onPreferenceChange(NavigationTitleKey.self) { value in + trace.record("preference:\(value)") + } + .appHeader { GrowingHeader(model: model) } + } + } +} + /// A subtree present in every pass of every frame: its lifetime effects /// must behave identically for single-pass and correction frames. private struct StableAcrossCorrectionApp: App { diff --git a/Tests/TUIkitTests/PreferenceStorageTests.swift b/Tests/TUIkitTests/PreferenceStorageTests.swift index 9edf3a36..39d97887 100644 --- a/Tests/TUIkitTests/PreferenceStorageTests.swift +++ b/Tests/TUIkitTests/PreferenceStorageTests.swift @@ -97,39 +97,19 @@ struct PreferenceStorageTests { #expect(storage.current[StorageStringKey.self] == "value") } - @Test("onPreferenceChange callback is triggered") - func changeCallback() { - let storage = PreferenceStorage() - nonisolated(unsafe) var received: String? - storage.onPreferenceChange(StorageStringKey.self) { value in - received = value - } - storage.setValue("updated", forKey: StorageStringKey.self) - #expect(received == "updated") - } - - @Test("Multiple callbacks for same key all fire") - func multipleCallbacks() { - let storage = PreferenceStorage() - nonisolated(unsafe) var count = 0 - storage.onPreferenceChange(StorageStringKey.self) { _ in count += 1 } - storage.onPreferenceChange(StorageStringKey.self) { _ in count += 1 } - storage.setValue("trigger", forKey: StorageStringKey.self) - #expect(count == 2) - } + // Change notification is no longer a PreferenceStorage concern: the + // onPreferenceChange VIEW modifier compares the subtree value against + // the last committed frame and fires at frame commit (see + // OnPreferenceChangeModifier and PendingEffectCommitTests). - @Test("beginRenderPass resets callbacks and stack") + @Test("beginRenderPass resets the value stack") func beginRenderPass() { let storage = PreferenceStorage() storage.setValue("old", forKey: StorageStringKey.self) - nonisolated(unsafe) var callbackFired = false - storage.onPreferenceChange(StorageStringKey.self) { _ in callbackFired = true } storage.beginRenderPass() #expect(storage.current[StorageStringKey.self] == "default") - storage.setValue("new", forKey: StorageStringKey.self) - #expect(callbackFired == false) } @Test("reset clears everything") diff --git a/Tests/TUIkitTests/RenderPassCollectorTests.swift b/Tests/TUIkitTests/RenderPassCollectorTests.swift index 0b2a7d91..8f80ddd6 100644 --- a/Tests/TUIkitTests/RenderPassCollectorTests.swift +++ b/Tests/TUIkitTests/RenderPassCollectorTests.swift @@ -28,19 +28,6 @@ struct RenderPassCollectorTests { #expect(harness.tuiContext.appHeader.height == 0) } - @Test("Preference callbacks do not accumulate across passes of one frame") - func preferenceCallbacksDoNotAccumulateAcrossPasses() { - let harness = FrameHarness(app: CorrectionPreferenceApp()) - - // Frame 1 establishes the header height estimate; growing the header - // forces frame 2 through the correction pass. - harness.renderFrame() - harness.app.model.lineCount = 3 - harness.renderFrame() - - #expect(harness.tuiContext.preferences.callbackCount == 1) - } - @Test("Status-bar items from a superseded pass do not persist") func statusBarItemsFromSupersededPassDoNotPersist() { let harness = FrameHarness(app: CorrectionGatedStatusBarApp()) @@ -126,21 +113,6 @@ private struct MeasurePhaseOnlyHeaderView: View, Renderable { } } -private struct CorrectionPreferenceApp: App { - let model = GrowableHeaderModel() - - init() {} - - var body: some Scene { - WindowGroup { - Text("body") - .preference(key: NavigationTitleKey.self, value: "title") - .onPreferenceChange(NavigationTitleKey.self) { _ in } - .appHeader { GrowingHeader(model: model) } - } - } -} - /// Status-bar items gated at ≥ 21 rows: present in frame 2's superseded /// main pass (22), absent from its corrected final tree (20). private struct CorrectionGatedStatusBarApp: App { diff --git a/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift b/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift index cecd1fdc..edf46a15 100644 --- a/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift +++ b/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift @@ -60,12 +60,7 @@ struct RenderPhaseCharacterizationTests { harness.renderFrame() - withKnownIssue("Issue #57: each pass claims a fresh onChange index and re-fires") { - #expect(harness.app.counter.value == 1) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(harness.app.counter.value == 1) } } From bce3171ef6927b14a8394a3ff644b50c12a64bb3 Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 22:03:47 +0200 Subject: [PATCH 4/5] Fix: Run runtime GC on the committed tree only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route all GC liveness (state, cache, observation) through the per-pass pending sets; the frame commit applies only the final pass's sets before the end-of-frame sweeps - Decouple observation liveness from track(): a registration made by a discarded pass is collected and its callback turns inert - Evaluate bodies plainly in the measure phase — sizing never registers invalidation callbacks - Promote the measure-pass observation and ghost-state tests to hard expectations; adapt registry unit tests to explicit liveness --- Sources/TUIkit/App/RenderLoop.swift | 4 ++++ Sources/TUIkit/Environment/TUIContext.swift | 24 +++++++++++++++++++ Sources/TUIkit/Focus/FocusRegistration.swift | 8 ++++++- .../TUIkit/Modifiers/OnChangeModifier.swift | 9 +++++-- Sources/TUIkitView/Core/EquatableView.swift | 19 +++++++++++---- Sources/TUIkitView/Rendering/Renderable.swift | 18 ++++++++++++-- .../State/ObservationRegistry.swift | 21 +++++++++++++--- .../PendingEffectCommitTests.swift | 14 ++--------- .../ObservationRegistryTests.swift | 12 ++++++---- 9 files changed, 101 insertions(+), 28 deletions(-) diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 065b8ddc..4bf2e60a 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -312,6 +312,10 @@ extension RenderLoop { // dropped with their collectors and never run. collectors.pendingEffects.commitDeferredEffects() + // COMMIT (step 6d): GC on the committed tree only — the final + // pass's liveness sets reach the managers, then endRenderPass + // sweeps everything that only discarded passes touched. + tuiContext.applyFrameLiveness(from: collectors.pendingEffects) endRenderPass() } diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index f524d166..cdf21c2e 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -535,6 +535,30 @@ extension TUIContext { renderCache.logFrameStats() } + /// Applies the committed frame's GC liveness to the identity-based + /// managers. + /// + /// This is commit step "6d preparation" of the frame choreography: the + /// FINAL pass's liveness sets (collected in ``PendingFrameEffects``) + /// mark state, cache, and observation records alive, so the subsequent + /// ``endRenderPass()`` sweeps everything that only discarded passes + /// touched. Runs after the deferred-effect replay, which may add its own + /// direct markings (e.g. preference change tracking). + /// + /// - Parameter pendingEffects: The final pass's pending records. + func applyFrameLiveness(from pendingEffects: PendingFrameEffects) { + for identity in pendingEffects.activeIdentities { + stateStorage.markActive(identity) + renderCache.markActive(identity) + observationRegistry.markActive(identity) + } + for root in pendingEffects.activeSubtreeRoots { + stateStorage.markSubtreeActive(root) + observationRegistry.markSubtreeActive(root) + renderCache.markActive(root) + } + } + /// Builds complete environment values for this runtime. func environmentValues( extending base: EnvironmentValues = EnvironmentValues() diff --git a/Sources/TUIkit/Focus/FocusRegistration.swift b/Sources/TUIkit/Focus/FocusRegistration.swift index 84777d36..bd5b899d 100644 --- a/Sources/TUIkit/Focus/FocusRegistration.swift +++ b/Sources/TUIkit/Focus/FocusRegistration.swift @@ -106,7 +106,13 @@ struct FocusRegistration { static func register(context: RenderContext, handler: Focusable) { guard context.phase == .render else { return } context.environment.focusManager.register(handler, inSection: context.environment.activeFocusSectionID) - context.environment.stateStorage!.markActive(context.identity) + // Keep the persisted focusID alive through GC (per pass in a + // RenderLoop frame, directly on the live path). + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.markActive(context.identity) + } else { + context.environment.stateStorage!.markActive(context.identity) + } } /// Determines whether the given focusID currently has focus. diff --git a/Sources/TUIkit/Modifiers/OnChangeModifier.swift b/Sources/TUIkit/Modifiers/OnChangeModifier.swift index 2da6b13d..5c73bad1 100644 --- a/Sources/TUIkit/Modifiers/OnChangeModifier.swift +++ b/Sources/TUIkit/Modifiers/OnChangeModifier.swift @@ -63,8 +63,13 @@ extension OnChangeModifier: Renderable { storage.setTrackedValue(value, for: key) } - // Keep tracked values alive through GC - storage.markActive(context.identity) + // Keep tracked values alive through GC (per pass in a RenderLoop + // frame, directly on the live path). + if let pendingEffects { + pendingEffects.markActive(context.identity) + } else { + storage.markActive(context.identity) + } return TUIkitView.renderToBuffer(content, context: context) } diff --git a/Sources/TUIkitView/Core/EquatableView.swift b/Sources/TUIkitView/Core/EquatableView.swift index 0d80ea31..dd7ccb71 100644 --- a/Sources/TUIkitView/Core/EquatableView.swift +++ b/Sources/TUIkitView/Core/EquatableView.swift @@ -86,7 +86,13 @@ extension EquatableView: Renderable { let cache = context.environment.renderCache! let identity = context.identity - cache.markActive(identity) + // Cache liveness is a lifetime effect: collected per pass, applied + // from the FINAL pass at frame commit; the live path marks directly. + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.markActive(identity) + } else { + cache.markActive(identity) + } // Cache hit: view unchanged and context size matches if let cached = cache.lookup( @@ -123,10 +129,15 @@ private extension EquatableView { /// Marks the content's runtime records as active for end-of-pass cleanup. /// /// When returning a cached buffer, the subtree's views aren't visited. - /// Their state and Observation identities must still be marked active. + /// Their state and Observation identities must still be marked active — + /// per pass inside a RenderLoop frame, directly on the live path. func markSubtreeActive(context: RenderContext) { - context.environment.stateStorage!.markSubtreeActive(context.identity) - context.environment.observationRegistry?.markSubtreeActive(context.identity) + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.markSubtreeActive(context.identity) + } else { + context.environment.stateStorage!.markSubtreeActive(context.identity) + context.environment.observationRegistry?.markSubtreeActive(context.identity) + } } } diff --git a/Sources/TUIkitView/Rendering/Renderable.swift b/Sources/TUIkitView/Rendering/Renderable.swift index 33a9fe1b..960bad4f 100644 --- a/Sources/TUIkitView/Rendering/Renderable.swift +++ b/Sources/TUIkitView/Rendering/Renderable.swift @@ -173,11 +173,17 @@ public func renderToBuffer(_ view: V, context: RenderContext) -> FrameB if V.Body.self != Never.self { let childContext = context.withChildIdentity(type: V.Body.self) let invalidationSink = context.environment.renderInvalidationSink + let pendingEffects = context.environment.pendingFrameEffects // Wrap body evaluation in observation tracking so that any @Observable // property accessed during body triggers a re-render when mutated. + // Sizing traversals evaluate the body plainly: a measure pass must + // not register invalidation callbacks for trees that may never + // become visible. let body = StateRegistration.withHydration(of: view, context: context) { - if let observationRegistry = context.environment.observationRegistry { + if context.phase == .measure { + view.body + } else if let observationRegistry = context.environment.observationRegistry { observationRegistry.track( identity: context.identity, invalidationSink: invalidationSink @@ -193,7 +199,15 @@ public func renderToBuffer(_ view: V, context: RenderContext) -> FrameB } } - context.environment.stateStorage?.markActive(context.identity) + // GC liveness is a lifetime effect: inside a RenderLoop pass it is + // collected per pass and only the FINAL pass's set reaches the + // managers at frame commit. The live path marks directly. + if let pendingEffects { + pendingEffects.markActive(context.identity) + } else { + context.environment.stateStorage?.markActive(context.identity) + context.environment.observationRegistry?.markActive(context.identity) + } return renderToBuffer(body, context: childContext) } diff --git a/Sources/TUIkitView/State/ObservationRegistry.swift b/Sources/TUIkitView/State/ObservationRegistry.swift index c5a87356..d8d9e18b 100644 --- a/Sources/TUIkitView/State/ObservationRegistry.swift +++ b/Sources/TUIkitView/State/ObservationRegistry.swift @@ -46,6 +46,19 @@ package extension ObservationRegistry { } } + /// Keeps an identity's registration alive through the end-of-frame GC. + /// + /// Liveness is decoupled from ``track(identity:invalidationSink:_:)``: + /// inside a `RenderLoop` frame only the FINAL pass's identities are + /// marked (via the pending-effects liveness sets), so a registration + /// made by a discarded pass is collected by ``endRenderPass()`` and its + /// callback turns inert. + func markActive(_ identity: ViewIdentity) { + state.withLock { registry in + _ = registry.activeIdentities.insert(identity) + } + } + /// Keeps existing registrations below a cached subtree mounted. func markSubtreeActive(_ root: ViewIdentity) { state.withLock { registry in @@ -80,8 +93,11 @@ package extension ObservationRegistry { package extension ObservationRegistry { /// Evaluates a view body while tracking its observable dependencies. /// - /// A newer evaluation at the same identity supersedes the previous callback. - /// Only the current generation may invalidate the owning runtime. + /// A newer evaluation at the same identity supersedes the previous + /// callback. Only the current generation may invalidate the owning + /// runtime. Tracking does NOT mark the identity alive — callers route + /// liveness through ``markActive(_:)`` (directly on the live path, via + /// the pending-effects sets inside a `RenderLoop` frame). @MainActor func track( identity: ViewIdentity, @@ -92,7 +108,6 @@ package extension ObservationRegistry { registry.nextGeneration &+= 1 let generation = registry.nextGeneration registry.generations[identity] = generation - registry.activeIdentities.insert(identity) return generation } let weakSink = WeakInvalidationSink(invalidationSink) diff --git a/Tests/TUIkitTests/PendingEffectCommitTests.swift b/Tests/TUIkitTests/PendingEffectCommitTests.swift index b343f763..f0724142 100644 --- a/Tests/TUIkitTests/PendingEffectCommitTests.swift +++ b/Tests/TUIkitTests/PendingEffectCommitTests.swift @@ -70,12 +70,7 @@ struct PendingEffectCommitTests { // not invalidate the committed frame. harness.app.model.value += 1 - withKnownIssue("Issue #57: measure-pass observation trackings stay current") { - #expect(harness.tuiContext.appState.needsRender == false) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(harness.tuiContext.appState.needsRender == false) } @Test("State storage keeps records for the committed tree only") @@ -88,12 +83,7 @@ struct PendingEffectCommitTests { .map(\.path) .filter { $0.contains("MeasureOnlyStatefulLeaf") } - withKnownIssue("Issue #57: GC liveness is the union of all passes") { - #expect(ghostPaths.isEmpty) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(ghostPaths.isEmpty) } @Test("onPreferenceChange fires once per change, even across a correction frame") diff --git a/Tests/TUIkitViewTests/ObservationRegistryTests.swift b/Tests/TUIkitViewTests/ObservationRegistryTests.swift index b7d86b8a..77d932d0 100644 --- a/Tests/TUIkitViewTests/ObservationRegistryTests.swift +++ b/Tests/TUIkitViewTests/ObservationRegistryTests.swift @@ -24,12 +24,14 @@ struct ObservationRegistryTests { registry.track(identity: identity, invalidationSink: sink) { _ = model.value } + registry.markActive(identity) registry.endRenderPass() registry.beginRenderPass() registry.track(identity: identity, invalidationSink: sink) { _ = model.value } + registry.markActive(identity) registry.endRenderPass() #expect(registry.count == 1) @@ -52,9 +54,11 @@ struct ObservationRegistryTests { registry.track(identity: descendant, invalidationSink: sink) { _ = model.value } + registry.markActive(descendant) registry.track(identity: sibling, invalidationSink: sink) { _ = model.value } + registry.markActive(sibling) registry.endRenderPass() registry.beginRenderPass() @@ -79,6 +83,7 @@ struct ObservationRegistryTests { registry.track(identity: identity, invalidationSink: sink) { _ = model.value } + registry.markActive(identity) registry.endRenderPass() registry.beginRenderPass() @@ -99,12 +104,11 @@ struct ObservationRegistryTests { registry.beginRenderPass() for index in 0..<3 { - registry.track( - identity: ViewIdentity(path: "Root/\(index)"), - invalidationSink: sink - ) { + let identity = ViewIdentity(path: "Root/\(index)") + registry.track(identity: identity, invalidationSink: sink) { _ = model.value } + registry.markActive(identity) } registry.endRenderPass() From 5d394e636586ed21a6011194cfd3738dd1b837dd Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 21 Jul 2026 22:34:43 +0200 Subject: [PATCH 5/5] Refactor: Remove token lifecycle and hydration legacy - Delete the token-based LifecycleManager APIs; identity slots are the only lifecycle keying - Migrate Spinner, _ImageCore, and NotificationHost to identity slots with updateTask restart IDs (spinner no longer restarts its task every frame; the notification host drops its resetAppearance workaround; the image source doubles as the restart ID) - Route their state liveness and task mounts through the pending-effects commit - Remove the StateRegistration activeContext/counter/activeEnvironment fallbacks; TaskLocal scoping is the only hydration mechanism - Scope PreferenceStorage.adopt(from:) to package visibility - Migrate lifecycle, context, and environment tests to the identity APIs and TaskLocal scoping - Update review-policy overrides and regenerate the API compatibility manifest with the tool --- Sources/TUIkit/Environment/TUIContext.swift | 86 +---------- .../NotificationHostModifier.swift | 52 ++++--- Sources/TUIkit/Views/Image.swift | 5 +- Sources/TUIkit/Views/Spinner.swift | 69 +++++---- Sources/TUIkit/Views/_ImageCore.swift | 135 +++++++++++------- .../Environment/EnvironmentProperty.swift | 6 +- Sources/TUIkitView/State/State.swift | 27 +--- .../EnvironmentPropertyTests.swift | 64 ++++----- Tests/TUIkitTests/LifecycleManagerTests.swift | 113 +++++---------- .../ObservableEnvironmentTests.swift | 35 +++-- .../PendingEffectCommitTests.swift | 2 +- .../RuntimeCharacterizationTests.swift | 5 +- Tests/TUIkitTests/TUIContextTests.swift | 12 +- .../Configuration/compatibility-manifest.json | 20 --- .../Configuration/review-policy.json | 20 --- 15 files changed, 272 insertions(+), 379 deletions(-) diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index cdf21c2e..618b2e16 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -19,10 +19,6 @@ final class LifecycleManager: @unchecked Sendable { private struct Slot: Hashable, Sendable { let value: String - init(token: String) { - self.value = "token:\(token)" - } - init(identity: ViewIdentity) { self.value = "identity:\(identity.path)" } @@ -102,19 +98,13 @@ extension LifecycleManager { } } - /// Records that a view with the given token appeared. + /// Records an appearance for a structurally derived runtime slot. /// /// - Parameters: - /// - token: Unique identifier for the view. + /// - identity: The runtime slot's structural identity. /// - action: The onAppear action to execute. /// - Returns: True if this is the first appearance (action was executed). @discardableResult - func recordAppear(token: String, action: () -> Void) -> Bool { - recordAppear(slot: Slot(token: token), action: action) - } - - /// Records an appearance for a structurally derived runtime slot. - @discardableResult func recordAppear(identity: ViewIdentity, action: () -> Void) -> Bool { recordAppear(slot: Slot(identity: identity), action: action) } @@ -133,11 +123,6 @@ extension LifecycleManager { return false } - /// Checks if a view has appeared before. - func hasAppeared(token: String) -> Bool { - hasAppeared(slot: Slot(token: token)) - } - /// Checks whether a structural runtime slot has appeared. func hasAppeared(identity: ViewIdentity) -> Bool { hasAppeared(slot: Slot(identity: identity)) @@ -149,13 +134,8 @@ extension LifecycleManager { return appearedSlots.contains(slot) } - /// Removes the appeared state for a token so the next `recordAppear` - /// treats it as a fresh first appearance. - func resetAppearance(token: String) { - resetAppearance(slot: Slot(token: token)) - } - - /// Resets appearance state for a structural runtime slot. + /// Resets appearance state for a structural runtime slot so the next + /// `recordAppear` treats it as a fresh first appearance. func resetAppearance(identity: ViewIdentity) { resetAppearance(slot: Slot(identity: identity)) } @@ -166,16 +146,12 @@ extension LifecycleManager { lock.unlock() } - /// Registers a callback for when a view with the given token disappears. + /// Registers a callback for when a structurally derived runtime slot + /// disappears. /// /// - Parameters: - /// - token: Unique identifier for the view. + /// - identity: The runtime slot's structural identity. /// - action: The onDisappear action to execute. - func registerDisappear(token: String, action: @escaping () -> Void) { - registerDisappear(slot: Slot(token: token), action: action) - } - - /// Registers a callback for a structurally derived runtime slot. func registerDisappear(identity: ViewIdentity, action: @escaping () -> Void) { registerDisappear(slot: Slot(identity: identity), action: action) } @@ -186,11 +162,6 @@ extension LifecycleManager { disappearCallbacks[slot] = action } - /// Unregisters the disappear callback for the given token. - func unregisterDisappear(token: String) { - unregisterDisappear(slot: Slot(token: token)) - } - /// Unregisters a callback for a structurally derived runtime slot. func unregisterDisappear(identity: ViewIdentity) { unregisterDisappear(slot: Slot(identity: identity)) @@ -202,27 +173,6 @@ extension LifecycleManager { disappearCallbacks.removeValue(forKey: slot) } - /// Starts an async task associated with a lifecycle token. - /// - /// If a task already exists for the token, it is cancelled first. - /// - /// - Parameters: - /// - token: Unique identifier for the view. - /// - priority: The task priority. - /// - operation: The async operation to execute. - func startTask( - token: String, - priority: TaskPriority, - @_inheritActorContext operation: @escaping @isolated(any) @Sendable () async -> Void - ) { - replaceTask( - slot: Slot(token: token), - id: nil, - priority: priority, - operation: operation - ) - } - /// Starts or preserves a task at a structural slot. /// /// The task remains mounted across unchanged render passes. A changed ID @@ -255,11 +205,6 @@ extension LifecycleManager { return true } - /// Cancels and removes the task associated with the given token. - func cancelTask(token: String) { - cancelTask(slot: Slot(token: token)) - } - /// Cancels and removes a task at a structural runtime slot. func cancelTask(identity: ViewIdentity) { cancelTask(slot: Slot(identity: identity)) @@ -303,23 +248,6 @@ extension LifecycleManager { task.cancel() } } - - private func replaceTask( - slot: Slot, - id: TaskID?, - priority: TaskPriority, - operation: @escaping @isolated(any) @Sendable () async -> Void - ) { - lock.lock() - currentRenderSlots.insert(slot) - let previousTask = tasks.removeValue(forKey: slot)?.task - previousTask?.cancel() - let task = Task(priority: priority) { - await operation() - } - tasks[slot] = TaskRecord(id: id, task: task) - lock.unlock() - } } // MARK: - TUI Context diff --git a/Sources/TUIkit/Notification/NotificationHostModifier.swift b/Sources/TUIkit/Notification/NotificationHostModifier.swift index ebcb4c43..353cc69b 100644 --- a/Sources/TUIkit/Notification/NotificationHostModifier.swift +++ b/Sources/TUIkit/Notification/NotificationHostModifier.swift @@ -51,13 +51,25 @@ extension NotificationHostModifier: Renderable { return baseBuffer } - // Start the animation invalidation task if not already running. - startAnimationTask( - entries: activeEntries, - lifecycle: context.environment.lifecycle!, - clock: context.environment.runtimeClock, - invalidationSink: context.environment.renderInvalidationSink - ) + // Mount the animation invalidation task (lifetime effect: recorded + // for the frame commit inside RenderLoop passes, immediate on the + // live path; sizing passes mount nothing). + if context.phase == .render { + let mountTask = { + mountAnimationTask( + entries: activeEntries, + taskIdentity: context.identity.scoped("notification.animation"), + lifecycle: context.environment.lifecycle!, + clock: context.environment.runtimeClock, + invalidationSink: context.environment.renderInvalidationSink + ) + } + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.recordEffect(mountTask) + } else { + mountTask() + } + } let now = context.environment.runtimeClock.now() let palette = context.environment.palette @@ -144,28 +156,31 @@ private extension NotificationHostModifier { return (xPosition, 1) } - /// Starts a background task that triggers re-renders for fade animations - /// and cleans up expired notifications. + /// Mounts the background task that triggers re-renders for fade + /// animations and cleans up expired notifications. /// - /// Uses a single shared token so only one animation task runs at a time. - /// The task stops automatically when no notifications are active. - func startAnimationTask( + /// The latest expiration time doubles as the task's restart identity: + /// posting a new notification extends the deadline and restarts the + /// task, while re-rendering with an unchanged entry set keeps the + /// mounted task running. When all notifications expired, the host stops + /// mounting and the end-of-frame sweep cancels the slot. + func mountAnimationTask( entries: [NotificationEntry], + taskIdentity: ViewIdentity, lifecycle: LifecycleManager, clock: RuntimeClock, invalidationSink: (any RenderInvalidationSink)? ) { - let token = "notification-host-animation" - - guard !lifecycle.hasAppeared(token: token) else { return } - _ = lifecycle.recordAppear(token: token) {} - // Calculate the latest expiration time across all entries. let totalOverhead = NotificationTiming.fadeInDuration + NotificationTiming.fadeOutDuration let latestExpiry = entries.map { $0.postedAt + $0.duration + totalOverhead } .max() ?? 0 - lifecycle.startTask(token: token, priority: .medium) { [lifecycle, invalidationSink] in + lifecycle.updateTask( + identity: taskIdentity, + id: latestExpiry, + priority: .medium + ) { [invalidationSink] in let triggerNanos: UInt64 = 23_800_000 // ~42 animation frames per second while !Task.isCancelled { @@ -179,7 +194,6 @@ private extension NotificationHostModifier { } // Final render to clear expired notifications. - lifecycle.resetAppearance(token: token) invalidationSink?.invalidate(.renderOnly) } } diff --git a/Sources/TUIkit/Views/Image.swift b/Sources/TUIkit/Views/Image.swift index 6d8eb049..a1f6eae0 100644 --- a/Sources/TUIkit/Views/Image.swift +++ b/Sources/TUIkit/Views/Image.swift @@ -9,7 +9,10 @@ import Foundation // MARK: - Image Source /// Describes where to load an image from. -public enum ImageSource: Sendable, Equatable { +/// +/// Hashable so it can serve as the restart identity of the loading task: +/// an unchanged source keeps the mounted task, a changed source restarts it. +public enum ImageSource: Sendable, Hashable { /// Load from a local file path. case file(String) diff --git a/Sources/TUIkit/Views/Spinner.swift b/Sources/TUIkit/Views/Spinner.swift index 210e3fb5..1301be8d 100644 --- a/Sources/TUIkit/Views/Spinner.swift +++ b/Sources/TUIkit/Views/Spinner.swift @@ -226,9 +226,6 @@ public struct Spinner: View { /// The spinner color (uses theme accent if nil). let color: Color? - /// Unique lifecycle token for this spinner instance. - let token: String - /// Creates a spinner with an optional label. /// /// - Parameters: @@ -243,27 +240,35 @@ public struct Spinner: View { self.label = label self.style = style self.color = color - self.token = "spinner-\(UUID().uuidString)" } public var body: some View { _SpinnerCore( label: label, style: style, - color: color, - token: token + color: color ) } } // MARK: - Internal Core View +/// Named property indices for `_SpinnerCore` state storage. +private enum StateIndex { + /// Stores the animation start time (`Double`). + static let startTime = 0 +} + +/// Stable restart identity for the spinner's animation task. +private enum SpinnerTaskID: Hashable { + case animation +} + /// Internal view that handles the actual rendering and animation of Spinner. private struct _SpinnerCore: View, Renderable { let label: String? let style: SpinnerStyle let color: Color? - let token: String var body: Never { fatalError("_SpinnerCore renders via Renderable") @@ -274,31 +279,43 @@ private struct _SpinnerCore: View, Renderable { let stateStorage = context.environment.stateStorage! let clock = context.environment.runtimeClock let invalidationSink = context.environment.renderInvalidationSink + let pendingEffects = context.environment.pendingFrameEffects // Retrieve or create persistent start time for this spinner. - let timeKey = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) + let timeKey = StateStorage.StateKey(identity: context.identity, propertyIndex: StateIndex.startTime) let startTimeBox: StateBox = stateStorage.storage(for: timeKey, default: clock.now()) - stateStorage.markActive(context.identity) - - // Start render-trigger task on first appearance. - if !lifecycle.hasAppeared(token: token) { - _ = lifecycle.recordAppear(token: token) {} - - let triggerNanos: UInt64 = 23_800_000 // ~42 animation frames per second - lifecycle.startTask(token: token, priority: .medium) { [invalidationSink] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: triggerNanos) - guard !Task.isCancelled else { break } - invalidationSink?.invalidate(.renderOnly) - } - } + if let pendingEffects { + pendingEffects.markActive(context.identity) } else { - _ = lifecycle.recordAppear(token: token) {} + stateStorage.markActive(context.identity) } - // Register disappear callback to cancel the animation task. - lifecycle.registerDisappear(token: token) { [lifecycle] in - lifecycle.cancelTask(token: token) + // The render-trigger task is a lifetime effect bound to this + // spinner's structural identity: mounted once, kept alive across + // frames by the stable restart ID, cancelled by the end-of-frame + // sweep when the spinner leaves the tree. Recorded for the frame + // commit inside RenderLoop passes; immediate on the live path. + if context.phase == .render { + let taskIdentity = context.identity.scoped("spinner.animation") + let mountAnimationTask = { [invalidationSink] in + _ = lifecycle.updateTask( + identity: taskIdentity, + id: SpinnerTaskID.animation, + priority: .medium + ) { + let triggerNanos: UInt64 = 23_800_000 // ~42 animation frames per second + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: triggerNanos) + guard !Task.isCancelled else { break } + invalidationSink?.invalidate(.renderOnly) + } + } + } + if let pendingEffects { + pendingEffects.recordEffect(mountAnimationTask) + } else { + mountAnimationTask() + } } // Calculate frame index from elapsed time. diff --git a/Sources/TUIkit/Views/_ImageCore.swift b/Sources/TUIkit/Views/_ImageCore.swift index a37a3009..b994a255 100644 --- a/Sources/TUIkit/Views/_ImageCore.swift +++ b/Sources/TUIkit/Views/_ImageCore.swift @@ -4,6 +4,8 @@ // Created by LAYERED.work // License: MIT +import Foundation + // MARK: - State Indices /// Named property indices for `_ImageCore` state storage. @@ -69,55 +71,38 @@ struct _ImageCore: View, Renderable, Layoutable { // Retrieve or create persistent phase state let phaseKey = StateStorage.StateKey(identity: identity, propertyIndex: StateIndex.phase) let phaseBox: StateBox = stateStorage.storage(for: phaseKey, default: .loading) - stateStorage.markActive(identity) + let pendingEffects = context.environment.pendingFrameEffects + if let pendingEffects { + pendingEffects.markActive(identity) + } else { + stateStorage.markActive(identity) + } // Track the last loaded source to detect changes let sourceKey = StateStorage.StateKey(identity: identity, propertyIndex: StateIndex.lastSource) let lastSourceBox: StateBox = stateStorage.storage(for: sourceKey, default: nil) - // Build a unique token for this image source - let token = "image-\(identity.path)" - - // Detect source change and force reload - resetLoadingPhaseIfNeeded(phaseBox: phaseBox, lastSourceBox: lastSourceBox, lifecycle: lifecycle, token: token) - - // Start loading on first appearance - if !lifecycle.hasAppeared(token: token) { - _ = lifecycle.recordAppear(token: token) {} - - let src = source - lifecycle.startTask(token: token, priority: .userInitiated) { [imageLoader, imageCache] in - do { - let rawImage: RGBAImage - switch src { - case .file(let path): - rawImage = try imageLoader.loadImage(from: path, maxPixelCount: maxPixelCount) - case .url(let urlString): - rawImage = try imageLoader.loadImage( - from: urlString, - cache: imageCache, - timeout: urlTimeout, - maxPixelCount: maxPixelCount - ) - } - - // Store the raw image; conversion happens per render pass. - // StateBox.didSet triggers setNeedsRender() automatically. - // The runtime delivers that invalidation to its async event loop. - phaseBox.value = .success(rawImage) - } catch let loadError as ImageLoadError { - phaseBox.value = .failure(loadError.description) - } catch { - phaseBox.value = .failure(error.localizedDescription) - } - } - } else { - _ = lifecycle.recordAppear(token: token) {} - } - - // Cancel loading task on disappear - lifecycle.registerDisappear(token: token) { [lifecycle] in - lifecycle.cancelTask(token: token) + // Detect source change and show the placeholder for the new load. + // Task cancellation and restart are handled by the updateTask + // restart ID below (the source itself). + resetLoadingPhaseIfNeeded(phaseBox: phaseBox, lastSourceBox: lastSourceBox) + + // The loading task is a lifetime effect bound to this image's + // structural identity. The SOURCE is the restart ID: an unchanged + // source keeps the mounted task, a changed source cancels it and + // starts exactly one replacement. Recorded for the frame commit + // inside RenderLoop passes; immediate on the live path. + if context.phase == .render { + mountLoadingTask( + phaseBox: phaseBox, + identity: identity, + context: context, + lifecycle: lifecycle, + imageLoader: imageLoader, + imageCache: imageCache, + urlTimeout: urlTimeout, + maxPixelCount: maxPixelCount + ) } // Render based on current phase @@ -153,16 +138,68 @@ struct _ImageCore: View, Renderable, Layoutable { extension _ImageCore { + /// Mounts the async loading task for the current source (lifetime + /// effect: recorded for the frame commit inside RenderLoop passes, + /// immediate on the live path). + private func mountLoadingTask( + phaseBox: StateBox, + identity: ViewIdentity, + context: RenderContext, + lifecycle: LifecycleManager, + imageLoader: any ImageLoader, + imageCache: URLImageCache, + urlTimeout: TimeInterval, + maxPixelCount: Int? + ) { + let taskIdentity = identity.scoped("image.load") + let src = source + let mount = { [imageLoader, imageCache] in + _ = lifecycle.updateTask( + identity: taskIdentity, + id: src, + priority: .userInitiated + ) { + do { + let rawImage: RGBAImage + switch src { + case .file(let path): + rawImage = try imageLoader.loadImage(from: path, maxPixelCount: maxPixelCount) + case .url(let urlString): + rawImage = try imageLoader.loadImage( + from: urlString, + cache: imageCache, + timeout: urlTimeout, + maxPixelCount: maxPixelCount + ) + } + + // Store the raw image; conversion happens per render pass. + // StateBox.didSet triggers setNeedsRender() automatically. + // The runtime delivers that invalidation to its async event loop. + phaseBox.value = .success(rawImage) + } catch let loadError as ImageLoadError { + phaseBox.value = .failure(loadError.description) + } catch { + phaseBox.value = .failure(error.localizedDescription) + } + } + } + if let pendingEffects = context.environment.pendingFrameEffects { + pendingEffects.recordEffect(mount) + } else { + mount() + } + } + /// Resets the loading phase when the image source changes. + /// + /// Mutates state during rendering on purpose: the placeholder must show + /// in the SAME frame the new source appears, not one frame later. private func resetLoadingPhaseIfNeeded( phaseBox: StateBox, - lastSourceBox: StateBox, - lifecycle: LifecycleManager, - token: String + lastSourceBox: StateBox ) { if let lastSource = lastSourceBox.value, lastSource != source { - lifecycle.cancelTask(token: token) - lifecycle.resetAppearance(token: token) phaseBox.value = .loading } lastSourceBox.value = source diff --git a/Sources/TUIkitView/Environment/EnvironmentProperty.swift b/Sources/TUIkitView/Environment/EnvironmentProperty.swift index 0230b95b..23c55c95 100644 --- a/Sources/TUIkitView/Environment/EnvironmentProperty.swift +++ b/Sources/TUIkitView/Environment/EnvironmentProperty.swift @@ -50,9 +50,9 @@ import TUIkitCore /// /// # How It Works /// -/// The rendering pipeline sets ``StateRegistration/activeEnvironment`` -/// before evaluating each view's `body`. When your code accesses -/// `wrappedValue`, it reads from the active environment. This ensures +/// The rendering pipeline scopes `StateRegistration.runtimeEnvironment` +/// around each view's `body` evaluation. When your code accesses +/// `wrappedValue`, it reads from that scoped environment. This ensures /// that `.environment()` modifiers applied by parent views are visible. /// /// Outside the render tree (e.g., in tests without a render context), diff --git a/Sources/TUIkitView/State/State.swift b/Sources/TUIkitView/State/State.swift index 7ca4a312..24ab8483 100644 --- a/Sources/TUIkitView/State/State.swift +++ b/Sources/TUIkitView/State/State.swift @@ -220,33 +220,14 @@ public enum StateRegistration { /// Dynamically scoped environment used by production rendering. @TaskLocal package static var runtimeEnvironment: EnvironmentValues? - /// The active hydration context, set during composite view body evaluation. - /// - /// Legacy mutable fallback retained for source compatibility. Production - /// rendering uses `runtimeContext` instead. - nonisolated(unsafe) public static var activeContext: HydrationContext? - - /// Legacy ambient property counter retained for source compatibility. - /// - /// Runtime `State` binding derives indices from reflected property order and - /// does not use this value. - nonisolated(unsafe) public static var counter: Int = 0 - - /// The active environment values, set during composite view body evaluation. - /// - /// Used by `@Environment` to read environment values during `body` evaluation. - /// Legacy mutable fallback retained for source compatibility. Production - /// rendering uses `runtimeEnvironment` instead. - nonisolated(unsafe) public static var activeEnvironment: EnvironmentValues? - - /// Current dynamically scoped context, including the compatibility fallback. + /// Current dynamically scoped context. package static var currentContext: HydrationContext? { - runtimeContext ?? activeContext + runtimeContext } - /// Current dynamically scoped environment, including the compatibility fallback. + /// Current dynamically scoped environment. package static var currentEnvironment: EnvironmentValues? { - runtimeEnvironment ?? activeEnvironment + runtimeEnvironment } /// Evaluates a closure with a hydration context active. diff --git a/Tests/TUIkitTests/EnvironmentPropertyTests.swift b/Tests/TUIkitTests/EnvironmentPropertyTests.swift index 09b3cb2e..c9c7aea1 100644 --- a/Tests/TUIkitTests/EnvironmentPropertyTests.swift +++ b/Tests/TUIkitTests/EnvironmentPropertyTests.swift @@ -5,6 +5,7 @@ // License: MIT import Testing +import TUIkitView @testable import TUIkit @@ -38,31 +39,25 @@ struct EnvironmentPropertyTests { @Test("Reads default value outside render context") func readsDefaultOutsideRenderContext() { - // Ensure no active environment - StateRegistration.activeEnvironment = nil - let wrapper = Environment(\.testColor) #expect(wrapper.wrappedValue == "blue") } @Test("Reads default int value outside render context") func readsDefaultIntOutsideRenderContext() { - StateRegistration.activeEnvironment = nil - let wrapper = Environment(\.testSize) #expect(wrapper.wrappedValue == 42) } - @Test("Reads value from active environment") - func readsFromActiveEnvironment() { + @Test("Reads value from the scoped runtime environment") + func readsFromRuntimeEnvironment() { var env = EnvironmentValues() env.testColor = "red" - StateRegistration.activeEnvironment = env let wrapper = Environment(\.testColor) - #expect(wrapper.wrappedValue == "red") - - StateRegistration.activeEnvironment = nil + StateRegistration.$runtimeEnvironment.withValue(env) { + #expect(wrapper.wrappedValue == "red") + } } @Test("Multiple @Environment properties read independently") @@ -70,17 +65,16 @@ struct EnvironmentPropertyTests { var env = EnvironmentValues() env.testColor = "green" env.testSize = 100 - StateRegistration.activeEnvironment = env let colorWrapper = Environment(\.testColor) let sizeWrapper = Environment(\.testSize) - #expect(colorWrapper.wrappedValue == "green") - #expect(sizeWrapper.wrappedValue == 100) - - StateRegistration.activeEnvironment = nil + StateRegistration.$runtimeEnvironment.withValue(env) { + #expect(colorWrapper.wrappedValue == "green") + #expect(sizeWrapper.wrappedValue == 100) + } } - @Test("Reads dynamically from current active environment") + @Test("Reads dynamically from the current scoped environment") func readsDynamically() { var env1 = EnvironmentValues() env1.testColor = "red" @@ -90,14 +84,13 @@ struct EnvironmentPropertyTests { let wrapper = Environment(\.testColor) - StateRegistration.activeEnvironment = env1 - #expect(wrapper.wrappedValue == "red") - - StateRegistration.activeEnvironment = env2 - #expect(wrapper.wrappedValue == "yellow") - - StateRegistration.activeEnvironment = nil - #expect(wrapper.wrappedValue == "blue") // default + StateRegistration.$runtimeEnvironment.withValue(env1) { + #expect(wrapper.wrappedValue == "red") + } + StateRegistration.$runtimeEnvironment.withValue(env2) { + #expect(wrapper.wrappedValue == "yellow") + } + #expect(wrapper.wrappedValue == "blue") // default outside any scope } @Test("Environment propagates through render pipeline") @@ -118,7 +111,7 @@ struct EnvironmentPropertyTests { #expect(!buffer.isEmpty) } - @Test("Nested environment overrides resolve correctly") + @Test("Nested environment scopes resolve correctly") func nestedOverrides() { var outerEnv = EnvironmentValues() outerEnv.testColor = "outer" @@ -128,18 +121,15 @@ struct EnvironmentPropertyTests { let wrapper = Environment(\.testColor) - // Simulate nested render: outer sets env, inner overrides - StateRegistration.activeEnvironment = outerEnv - #expect(wrapper.wrappedValue == "outer") - - // Inner override - StateRegistration.activeEnvironment = innerEnv - #expect(wrapper.wrappedValue == "inner") + StateRegistration.$runtimeEnvironment.withValue(outerEnv) { + #expect(wrapper.wrappedValue == "outer") - // Restore outer (like render pipeline does) - StateRegistration.activeEnvironment = outerEnv - #expect(wrapper.wrappedValue == "outer") + StateRegistration.$runtimeEnvironment.withValue(innerEnv) { + #expect(wrapper.wrappedValue == "inner") + } - StateRegistration.activeEnvironment = nil + // The outer scope is restored automatically. + #expect(wrapper.wrappedValue == "outer") + } } } diff --git a/Tests/TUIkitTests/LifecycleManagerTests.swift b/Tests/TUIkitTests/LifecycleManagerTests.swift index f653407c..e5a1516f 100644 --- a/Tests/TUIkitTests/LifecycleManagerTests.swift +++ b/Tests/TUIkitTests/LifecycleManagerTests.swift @@ -20,7 +20,7 @@ struct LifecycleManagerAppearTests { func firstAppearance() { let manager = LifecycleManager() nonisolated(unsafe) var actionCalled = false - let result = manager.recordAppear(token: "view-1") { + let result = manager.recordAppear(identity: ViewIdentity(path: "view-1")) { actionCalled = true } #expect(result == true) @@ -30,9 +30,9 @@ struct LifecycleManagerAppearTests { @Test("recordAppear returns false on repeated appearance") func repeatedAppearance() { let manager = LifecycleManager() - _ = manager.recordAppear(token: "view-1") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} nonisolated(unsafe) var secondCalled = false - let result = manager.recordAppear(token: "view-1") { + let result = manager.recordAppear(identity: ViewIdentity(path: "view-1")) { secondCalled = true } #expect(result == false) @@ -42,34 +42,34 @@ struct LifecycleManagerAppearTests { @Test("hasAppeared returns false for unseen token") func hasNotAppeared() { let manager = LifecycleManager() - #expect(manager.hasAppeared(token: "never-seen") == false) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "never-seen")) == false) } @Test("hasAppeared returns true after recordAppear") func hasAppearedAfterRecord() { let manager = LifecycleManager() - _ = manager.recordAppear(token: "view-1") {} - #expect(manager.hasAppeared(token: "view-1") == true) + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} + #expect(manager.hasAppeared(identity: ViewIdentity(path: "view-1")) == true) } @Test("Multiple tokens are tracked independently") func independentTokens() { let manager = LifecycleManager() - _ = manager.recordAppear(token: "a") {} - _ = manager.recordAppear(token: "b") {} - #expect(manager.hasAppeared(token: "a") == true) - #expect(manager.hasAppeared(token: "b") == true) - #expect(manager.hasAppeared(token: "c") == false) + _ = manager.recordAppear(identity: ViewIdentity(path: "a")) {} + _ = manager.recordAppear(identity: ViewIdentity(path: "b")) {} + #expect(manager.hasAppeared(identity: ViewIdentity(path: "a")) == true) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "b")) == true) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "c")) == false) } @Test("reset clears all appeared tokens") func resetClears() { let manager = LifecycleManager() - _ = manager.recordAppear(token: "view-1") {} - _ = manager.recordAppear(token: "view-2") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-2")) {} manager.reset() - #expect(manager.hasAppeared(token: "view-1") == false) - #expect(manager.hasAppeared(token: "view-2") == false) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "view-1")) == false) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "view-2")) == false) } } @@ -84,14 +84,14 @@ struct LifecycleManagerRenderPassTests { let manager = LifecycleManager() // Pass 1: view appears manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} manager.endRenderPass() // sets visibleTokens = {"view-1"} // Pass 2: view does NOT appear manager.beginRenderPass() // clears currentRenderTokens manager.endRenderPass() // disappeared = {"view-1"}, removes from appearedTokens - #expect(manager.hasAppeared(token: "view-1") == false) + #expect(manager.hasAppeared(identity: ViewIdentity(path: "view-1")) == false) } @Test("endRenderPass triggers disappear for removed views") @@ -101,8 +101,8 @@ struct LifecycleManagerRenderPassTests { // Render pass 1: view appears manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} - manager.registerDisappear(token: "view-1") { + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) { disappeared = true } manager.endRenderPass() @@ -122,15 +122,15 @@ struct LifecycleManagerRenderPassTests { // Render pass 1 manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} - manager.registerDisappear(token: "view-1") { + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) { disappeared = true } manager.endRenderPass() // Render pass 2: view still rendered manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} manager.endRenderPass() #expect(disappeared == false) // Still visible, no disappear } @@ -142,7 +142,7 @@ struct LifecycleManagerRenderPassTests { // Pass 1: appear manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") { appearCount += 1 } + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) { appearCount += 1 } manager.endRenderPass() #expect(appearCount == 1) @@ -152,7 +152,7 @@ struct LifecycleManagerRenderPassTests { // Pass 3: reappear — action should fire again manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") { appearCount += 1 } + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) { appearCount += 1 } manager.endRenderPass() #expect(appearCount == 2) } @@ -168,7 +168,7 @@ struct LifecycleManagerDisappearTests { func registerStoresCallback() { let manager = LifecycleManager() nonisolated(unsafe) var called = false - manager.registerDisappear(token: "view-1") { + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) { called = true } // Callback is stored but not called yet @@ -179,14 +179,14 @@ struct LifecycleManagerDisappearTests { func unregisterRemoves() { let manager = LifecycleManager() nonisolated(unsafe) var called = false - manager.registerDisappear(token: "view-1") { + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) { called = true } - manager.unregisterDisappear(token: "view-1") + manager.unregisterDisappear(identity: ViewIdentity(path: "view-1")) // Simulate disappear — callback should NOT fire manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} manager.endRenderPass() manager.beginRenderPass() @@ -200,9 +200,9 @@ struct LifecycleManagerDisappearTests { let manager = LifecycleManager() manager.beginRenderPass() - _ = manager.recordAppear(token: "view-1") {} - manager.registerDisappear(token: "view-1") {} - manager.registerDisappear(token: "view-1") {} + _ = manager.recordAppear(identity: ViewIdentity(path: "view-1")) {} + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) {} + manager.registerDisappear(identity: ViewIdentity(path: "view-1")) {} manager.endRenderPass() #expect(manager.disappearCallbackCount == 1) @@ -315,13 +315,13 @@ struct LifecycleManagerTaskTests { #expect(state.value == 42) } - @Test("startTask runs its operation", .timeLimit(.minutes(1))) - func startTask() async { + @Test("updateTask runs its operation", .timeLimit(.minutes(1))) + func updateTaskRuns() async { let manager = LifecycleManager() let events = TraceRecorder() let started = AsyncSignal() - manager.startTask(token: "task-1", priority: .medium) { + manager.updateTask(identity: ViewIdentity(path: "task-1"), id: 1, priority: .medium) { events.record(.started("task-1")) started.signal() } @@ -339,7 +339,7 @@ struct LifecycleManagerTaskTests { let release = AsyncSignal() let completed = AsyncSignal() - manager.startTask(token: "task-1", priority: .medium) { + manager.updateTask(identity: ViewIdentity(path: "task-1"), id: 1, priority: .medium) { events.record(.started("task-1")) started.signal() await release.wait() @@ -349,7 +349,7 @@ struct LifecycleManagerTaskTests { await started.wait() - manager.cancelTask(token: "task-1") + manager.cancelTask(identity: ViewIdentity(path: "task-1")) release.signal() await completed.wait() @@ -359,43 +359,6 @@ struct LifecycleManagerTaskTests { ]) } - @Test("startTask cancels the existing task and runs its replacement", .timeLimit(.minutes(1))) - func replaceTask() async { - let manager = LifecycleManager() - let events = TraceRecorder() - let firstStarted = AsyncSignal() - let firstRelease = AsyncSignal() - let firstCompleted = AsyncSignal() - let replacementStarted = AsyncSignal() - - manager.startTask(token: "task-1", priority: .medium) { - events.record(.started("first")) - firstStarted.signal() - await firstRelease.wait() - events.record(.completed("first", wasCancelled: Task.isCancelled)) - firstCompleted.signal() - } - - await firstStarted.wait() - - manager.startTask(token: "task-1", priority: .medium) { - events.record(.started("replacement")) - replacementStarted.signal() - } - - firstRelease.signal() - await firstCompleted.wait() - await replacementStarted.wait() - - let snapshot = events.snapshot() - #expect(snapshot.count == 3) - #expect(Set(snapshot) == [ - .started("first"), - .completed("first", wasCancelled: true), - .started("replacement") - ]) - } - @Test("reset cancels all running tasks", .timeLimit(.minutes(1))) func resetWithRunningTasks() async { let manager = LifecycleManager() @@ -407,14 +370,14 @@ struct LifecycleManagerTaskTests { let secondRelease = AsyncSignal() let secondCompleted = AsyncSignal() - manager.startTask(token: "task-1", priority: .medium) { + manager.updateTask(identity: ViewIdentity(path: "task-1"), id: 1, priority: .medium) { events.record(.started("task-1")) firstStarted.signal() await firstRelease.wait() events.record(.completed("task-1", wasCancelled: Task.isCancelled)) firstCompleted.signal() } - manager.startTask(token: "task-2", priority: .medium) { + manager.updateTask(identity: ViewIdentity(path: "task-2"), id: 1, priority: .medium) { events.record(.started("task-2")) secondStarted.signal() await secondRelease.wait() diff --git a/Tests/TUIkitTests/ObservableEnvironmentTests.swift b/Tests/TUIkitTests/ObservableEnvironmentTests.swift index da20825c..5bd3f262 100644 --- a/Tests/TUIkitTests/ObservableEnvironmentTests.swift +++ b/Tests/TUIkitTests/ObservableEnvironmentTests.swift @@ -6,6 +6,7 @@ import Observation import Testing +import TUIkitView @testable import TUIkit @@ -50,20 +51,18 @@ struct ObservableEnvironmentTests { #expect(retrieved == nil) } - @Test("@Environment reads observable from active environment") + @Test("@Environment reads observable from the scoped runtime environment") func environmentReadsObservable() { var env = EnvironmentValues() let model = CounterModel() model.count = 99 env[observable: CounterModel.self] = model - StateRegistration.activeEnvironment = env - let wrapper = Environment(CounterModel.self) - #expect(wrapper.wrappedValue.count == 99) - #expect(wrapper.wrappedValue === model) - - StateRegistration.activeEnvironment = nil + StateRegistration.$runtimeEnvironment.withValue(env) { + #expect(wrapper.wrappedValue.count == 99) + #expect(wrapper.wrappedValue === model) + } } @Test("Inner .environment overrides outer for same type") @@ -82,13 +81,13 @@ struct ObservableEnvironmentTests { let wrapper = Environment(CounterModel.self) - StateRegistration.activeEnvironment = outerEnv - #expect(wrapper.wrappedValue.count == 1) - - StateRegistration.activeEnvironment = innerEnv - #expect(wrapper.wrappedValue.count == 2) + StateRegistration.$runtimeEnvironment.withValue(outerEnv) { + #expect(wrapper.wrappedValue.count == 1) - StateRegistration.activeEnvironment = nil + StateRegistration.$runtimeEnvironment.withValue(innerEnv) { + #expect(wrapper.wrappedValue.count == 2) + } + } } @Test("Different types coexist in environment") @@ -102,15 +101,13 @@ struct ObservableEnvironmentTests { env[observable: CounterModel.self] = counter env[observable: NameModel.self] = name - StateRegistration.activeEnvironment = env - let counterWrapper = Environment(CounterModel.self) let nameWrapper = Environment(NameModel.self) - #expect(counterWrapper.wrappedValue.count == 10) - #expect(nameWrapper.wrappedValue.name == "hello") - - StateRegistration.activeEnvironment = nil + StateRegistration.$runtimeEnvironment.withValue(env) { + #expect(counterWrapper.wrappedValue.count == 10) + #expect(nameWrapper.wrappedValue.name == "hello") + } } @Test("Observable propagates through render pipeline") diff --git a/Tests/TUIkitTests/PendingEffectCommitTests.swift b/Tests/TUIkitTests/PendingEffectCommitTests.swift index f0724142..06b2588e 100644 --- a/Tests/TUIkitTests/PendingEffectCommitTests.swift +++ b/Tests/TUIkitTests/PendingEffectCommitTests.swift @@ -97,7 +97,7 @@ struct PendingEffectCommitTests { // Unchanged value → no fire, regardless of re-rendering. harness.app.trace.reset() harness.renderFrame() - #expect(harness.app.trace.snapshot() == []) + #expect(harness.app.trace.snapshot().isEmpty) // Changed value in a frame that traverses twice (main + correction): // the action fires exactly once, for the committed tree. diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index 70aec56f..491a7246 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -303,10 +303,11 @@ private struct FixedLifecycleView: View, Renderable, Equatable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let lifecycle = context.environment.lifecycle! - _ = lifecycle.recordAppear(token: token) { + let slotIdentity = ViewIdentity(path: token) + _ = lifecycle.recordAppear(identity: slotIdentity) { trace.record(.lifecycle("appear:\(token)")) } - lifecycle.registerDisappear(token: token) { + lifecycle.registerDisappear(identity: slotIdentity) { trace.record(.lifecycle("disappear:\(token)")) } return FrameBuffer(text: token) diff --git a/Tests/TUIkitTests/TUIContextTests.swift b/Tests/TUIkitTests/TUIContextTests.swift index 52e20101..3b40a7c7 100644 --- a/Tests/TUIkitTests/TUIContextTests.swift +++ b/Tests/TUIkitTests/TUIContextTests.swift @@ -19,22 +19,24 @@ struct TUIContextTests { func independentServices() { let contextA = TUIContext() let contextB = TUIContext() + let slot = ViewIdentity(path: "a") // Each context has its own lifecycle manager - contextA.lifecycle.recordAppear(token: "a") {} - #expect(contextA.lifecycle.hasAppeared(token: "a") == true) - #expect(contextB.lifecycle.hasAppeared(token: "a") == false) + contextA.lifecycle.recordAppear(identity: slot) {} + #expect(contextA.lifecycle.hasAppeared(identity: slot) == true) + #expect(contextB.lifecycle.hasAppeared(identity: slot) == false) } @Test("reset clears all services") func resetClears() { let context = TUIContext() - context.lifecycle.recordAppear(token: "test") {} + let slot = ViewIdentity(path: "test") + context.lifecycle.recordAppear(identity: slot) {} context.preferences.setValue("value", forKey: TestContextStringKey.self) context.keyEventDispatcher.addHandler { _ in true } context.reset() - #expect(context.lifecycle.hasAppeared(token: "test") == false) + #expect(context.lifecycle.hasAppeared(identity: slot) == false) #expect(context.preferences.current[TestContextStringKey.self] == "default") } diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index f41c6acc..0d84fefc 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -337586,11 +337586,6 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitCore17PreferenceStorageC" }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitCore17PreferenceStorageC02onC6Change_8callbackyxm_y5ValueQzctAA0C3KeyRzlF" - }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -338991,26 +338986,11 @@ "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView17StateRegistrationO" }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView17StateRegistrationO13activeContextAA09HydrationF0VSgvpZ" - }, { "classification" : "implementationLeak", "ownerIssue" : "#35", "symbolID" : "s:10TUIkitView17StateRegistrationO13withHydration7context_xAA13RenderContextV_xyXEtlFZ" }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView17StateRegistrationO17activeEnvironment0A4Core0F6ValuesVSgvpZ" - }, - { - "classification" : "implementationLeak", - "ownerIssue" : "#35", - "symbolID" : "s:10TUIkitView17StateRegistrationO7counterSivpZ" - }, { "classification" : "implementationLeak", "ownerIssue" : "#35", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index bd2a702d..0477e833 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -18108,11 +18108,6 @@ "ownerIssue": "#35", "symbolID": "s:10TUIkitCore17PreferenceStorageC" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:10TUIkitCore17PreferenceStorageC02onC6Change_8callbackyxm_y5ValueQzctAA0C3KeyRzlF" - }, { "action": "implementationLeak", "ownerIssue": "#35", @@ -19478,26 +19473,11 @@ "ownerIssue": "#35", "symbolID": "s:10TUIkitView17StateRegistrationO" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:10TUIkitView17StateRegistrationO13activeContextAA09HydrationF0VSgvpZ" - }, { "action": "implementationLeak", "ownerIssue": "#35", "symbolID": "s:10TUIkitView17StateRegistrationO13withHydration7context_xAA13RenderContextV_xyXEtlFZ" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:10TUIkitView17StateRegistrationO17activeEnvironment0A4Core0F6ValuesVSgvpZ" - }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:10TUIkitView17StateRegistrationO7counterSivpZ" - }, { "action": "implementationLeak", "ownerIssue": "#35",