diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 78f8b3535..9ff405a24 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -69,7 +69,7 @@ internal struct RenderBackgroundCodes: Equatable { /// 3. Build EnvironmentValues from all subsystems /// 4. Create RenderContext with layout constraints /// 5. Evaluate App.body fresh → Scene (WindowGroup) -/// @State values survive because State.init self-hydrates from StateStorage +/// @State values bind to persistent storage at final structural identities /// 6. Call SceneRenderable.renderScene() → FrameBuffer /// 7. Convert FrameBuffer to terminal-ready output lines /// 8. Begin buffered frame (terminal.beginFrame()) @@ -286,23 +286,20 @@ private extension RenderLoop { /// Evaluates `App.body` with hydration and environment context active. /// - /// Sets up ``StateRegistration`` so `@State` self-hydrates from `StateStorage` - /// and `@Environment` reads from the current environment. Clears both - /// contexts after body evaluation. + /// Binds the app's dynamic properties to its root identity and makes the + /// current environment available while evaluating `body`. func evaluateAppBody(environment: EnvironmentValues) -> A.Body { let rootIdentity = ViewIdentity(rootType: A.self) - StateRegistration.activeContext = HydrationContext( - identity: rootIdentity, - storage: tuiContext.stateStorage, - invalidationSink: tuiContext.appState + let context = RenderContext( + availableWidth: 0, + availableHeight: 0, + environment: environment, + identity: rootIdentity ) - StateRegistration.counter = 0 - StateRegistration.activeEnvironment = environment - let scene = app.body - - StateRegistration.activeContext = nil - StateRegistration.activeEnvironment = nil + let scene = StateRegistration.withHydration(of: app, context: context) { + app.body + } tuiContext.stateStorage.markActive(rootIdentity) return scene diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index b12f986f2..b55e5967d 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -15,30 +15,53 @@ import Foundation /// into a single cohesive manager. /// All mutable state is protected by `NSLock`. final class LifecycleManager: @unchecked Sendable { + /// A stable key for one lifecycle or task slot. + private struct Slot: Hashable, Sendable { + let value: String + + init(token: String) { + self.value = "token:\(token)" + } + + init(identity: ViewIdentity) { + self.value = "identity:\(identity.path)" + } + } + + /// Type-erased task restart identity. + private struct TaskID: Equatable, @unchecked Sendable { + let value: AnyHashable + } + + /// Mounted task and the value controlling its restart behavior. + private struct TaskRecord: @unchecked Sendable { + let id: TaskID? + let task: Task + } /// Lock protecting all mutable state. private let lock = NSLock() // MARK: - Lifecycle Tracking - /// Set of tokens that have appeared. - private var appearedTokens: Set = [] + /// Set of lifecycle slots that have appeared. + private var appearedSlots: Set = [] - /// Set of tokens that are currently visible (for onDisappear tracking). - private var visibleTokens: Set = [] + /// Set of lifecycle slots that are currently visible. + private var visibleSlots: Set = [] - /// Tokens seen during the current render pass. - private var currentRenderTokens: Set = [] + /// Lifecycle slots seen during the current render pass. + private var currentRenderSlots: Set = [] // MARK: - Disappear Callbacks /// Callbacks registered for view disappearance. - private var disappearCallbacks: [String: () -> Void] = [:] + private var disappearCallbacks: [Slot: () -> Void] = [:] // MARK: - Task Storage - /// Running async tasks keyed by lifecycle token. - private var tasks: [String: Task] = [:] + /// Mounted async tasks keyed by structural lifecycle slot. + private var tasks: [Slot: TaskRecord] = [:] // MARK: - Init @@ -53,23 +76,29 @@ extension LifecycleManager { func beginRenderPass() { lock.lock() defer { lock.unlock() } - currentRenderTokens.removeAll() + currentRenderSlots.removeAll(keepingCapacity: true) } /// Marks the end of a render pass and triggers onDisappear for views that are no longer visible. func endRenderPass() { lock.lock() - let disappeared = visibleTokens.subtracting(currentRenderTokens) - for token in disappeared { - appearedTokens.remove(token) + let disappeared = visibleSlots.subtracting(currentRenderSlots).sorted { + $0.value < $1.value } - visibleTokens = currentRenderTokens - let callbacks = disappearCallbacks + for slot in disappeared { + appearedSlots.remove(slot) + } + visibleSlots = currentRenderSlots + let callbacks = disappeared.compactMap { disappearCallbacks.removeValue(forKey: $0) } + let removedTasks = disappeared.compactMap { tasks.removeValue(forKey: $0)?.task } lock.unlock() - // Execute callbacks outside the lock to avoid deadlocks - for token in disappeared { - callbacks[token]?() + // Cancellation and callbacks run outside the lock to avoid deadlocks. + for task in removedTasks { + task.cancel() + } + for callback in callbacks { + callback() } } @@ -81,11 +110,21 @@ extension LifecycleManager { /// - 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) + } + + private func recordAppear(slot: Slot, action: () -> Void) -> Bool { lock.lock() - currentRenderTokens.insert(token) + currentRenderSlots.insert(slot) - if !appearedTokens.contains(token) { - appearedTokens.insert(token) + if !appearedSlots.contains(slot) { + appearedSlots.insert(slot) lock.unlock() action() return true @@ -96,16 +135,34 @@ extension LifecycleManager { /// 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)) + } + + private func hasAppeared(slot: Slot) -> Bool { lock.lock() defer { lock.unlock() } - return appearedTokens.contains(token) + 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. + func resetAppearance(identity: ViewIdentity) { + resetAppearance(slot: Slot(identity: identity)) + } + + private func resetAppearance(slot: Slot) { lock.lock() - appearedTokens.remove(token) + appearedSlots.remove(slot) lock.unlock() } @@ -115,16 +172,34 @@ extension LifecycleManager { /// - token: Unique identifier for the view. /// - 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) + } + + private func registerDisappear(slot: Slot, action: @escaping () -> Void) { lock.lock() defer { lock.unlock() } - disappearCallbacks[token] = action + 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)) + } + + private func unregisterDisappear(slot: Slot) { lock.lock() defer { lock.unlock() } - disappearCallbacks.removeValue(forKey: token) + disappearCallbacks.removeValue(forKey: slot) } /// Starts an async task associated with a lifecycle token. @@ -138,22 +213,77 @@ extension LifecycleManager { func startTask( token: String, priority: TaskPriority, - operation: @escaping @Sendable () async -> Void + @_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 + /// cancels the existing task and starts exactly one replacement. + @discardableResult + func updateTask( + identity: ViewIdentity, + id: ID, + priority: TaskPriority, + @_inheritActorContext operation: @escaping @isolated(any) @Sendable () async -> Void + ) -> Bool { + let slot = Slot(identity: identity) + let taskID = TaskID(value: AnyHashable(id)) + lock.lock() - tasks[token]?.cancel() - tasks[token] = Task(priority: priority) { + currentRenderSlots.insert(slot) + if tasks[slot]?.id == taskID { + lock.unlock() + return false + } + + let previousTask = tasks.removeValue(forKey: slot)?.task + previousTask?.cancel() + let task = Task(priority: priority) { await operation() } + tasks[slot] = TaskRecord(id: taskID, task: task) lock.unlock() + + 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)) + } + + private func cancelTask(slot: Slot) { lock.lock() - tasks[token]?.cancel() - tasks.removeValue(forKey: token) + let task = tasks.removeValue(forKey: slot)?.task lock.unlock() + task?.cancel() + } + + /// Number of retained disappearance callbacks for tests and diagnostics. + var disappearCallbackCount: Int { + lock.lock() + defer { lock.unlock() } + return disappearCallbacks.count + } + + /// Number of retained mounted task records for tests and diagnostics. + var taskCount: Int { + lock.lock() + defer { lock.unlock() } + return tasks.count } /// Resets all lifecycle state. @@ -161,14 +291,33 @@ extension LifecycleManager { /// Cancels all running tasks, clears all callbacks and tracking state. func reset() { lock.lock() - appearedTokens.removeAll() - visibleTokens.removeAll() - currentRenderTokens.removeAll() + appearedSlots.removeAll() + visibleSlots.removeAll() + currentRenderSlots.removeAll() disappearCallbacks.removeAll() - for task in tasks.values { + let runningTasks = tasks.values.map(\.task) + tasks.removeAll() + lock.unlock() + + for task in runningTasks { task.cancel() } - tasks.removeAll() + } + + 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() } } @@ -220,6 +369,9 @@ final class TUIContext { /// Persistent `@State` value storage indexed by `ViewIdentity`. let stateStorage: StateStorage + /// Identity-bound Observation registrations for this runtime. + let observationRegistry: ObservationRegistry + /// Cache for memoized subtree rendering results. /// /// Stores rendered ``FrameBuffer`` output for ``EquatableView`` instances, @@ -271,6 +423,7 @@ final class TUIContext { /// - keyEventDispatcher: The key event dispatcher to use. /// - preferences: The preference storage to use. /// - stateStorage: The state storage to use. + /// - observationRegistry: The Observation registry to use. /// - renderCache: The render cache to use. /// - storageBackend: The AppStorage backend to use. /// - localizationService: Optional localization service to adopt. @@ -286,6 +439,7 @@ final class TUIContext { keyEventDispatcher: KeyEventDispatcher = KeyEventDispatcher(), preferences: PreferenceStorage = PreferenceStorage(), stateStorage: StateStorage = StateStorage(), + observationRegistry: ObservationRegistry = ObservationRegistry(), renderCache: RenderCache = RenderCache(), storageBackend: StorageBackend = VolatileStorageBackend(), localizationService: LocalizationService? = nil, @@ -316,6 +470,7 @@ final class TUIContext { self.keyEventDispatcher = keyEventDispatcher self.preferences = preferences self.stateStorage = stateStorage + self.observationRegistry = observationRegistry self.renderCache = renderCache self.localizationService = localizationService self.notificationService = notificationService @@ -358,6 +513,7 @@ extension TUIContext { statusBar.focusManager = focusManager lifecycle.beginRenderPass() stateStorage.beginRenderPass() + observationRegistry.beginRenderPass() renderCache.beginRenderPass() applyPendingRenderInvalidations() } @@ -366,6 +522,7 @@ extension TUIContext { func endRenderPass() { lifecycle.endRenderPass() stateStorage.endRenderPass() + observationRegistry.endRenderPass() renderCache.removeInactive() renderCache.logFrameStats() } @@ -376,6 +533,7 @@ extension TUIContext { ) -> EnvironmentValues { var environment = base environment.stateStorage = stateStorage + environment.observationRegistry = observationRegistry environment.lifecycle = lifecycle environment.keyEventDispatcher = keyEventDispatcher environment.renderCache = renderCache @@ -426,6 +584,7 @@ extension TUIContext { keyEventDispatcher.clearHandlers() preferences.reset() stateStorage.reset() + observationRegistry.reset() renderCache.reset() notificationService.clear() focusManager.clear() diff --git a/Sources/TUIkit/Extensions/View+Events.swift b/Sources/TUIkit/Extensions/View+Events.swift index d6c050f30..c6bd345a7 100644 --- a/Sources/TUIkit/Extensions/View+Events.swift +++ b/Sources/TUIkit/Extensions/View+Events.swift @@ -4,8 +4,6 @@ // Created by LAYERED.work // License: MIT -import Foundation - // MARK: - Key Press extension View { @@ -181,7 +179,6 @@ extension View { public func onAppear(perform action: @escaping () -> Void) -> some View { OnAppearModifier( content: self, - token: UUID().uuidString, action: action ) } @@ -208,7 +205,6 @@ extension View { public func onDisappear(perform action: @escaping () -> Void) -> some View { OnDisappearModifier( content: self, - token: UUID().uuidString, action: action ) } @@ -236,11 +232,10 @@ extension View { /// - Returns: A view that starts the task on appearance. public func task( priority: TaskPriority = .userInitiated, - _ action: @escaping @Sendable () async -> Void + @_inheritActorContext _ action: @escaping @Sendable () async -> Void ) -> some View { TaskModifier( content: self, - token: UUID().uuidString, task: action, priority: priority ) diff --git a/Sources/TUIkit/Localization/LocalizationExtensions.swift b/Sources/TUIkit/Localization/LocalizationExtensions.swift index 6b6246be3..289707dcd 100644 --- a/Sources/TUIkit/Localization/LocalizationExtensions.swift +++ b/Sources/TUIkit/Localization/LocalizationExtensions.swift @@ -20,7 +20,7 @@ extension Text { /// /// - Parameter key: The dot-notation key for the localized string. public init(localized key: String) { - let localizationService = StateRegistration.activeEnvironment?.localizationService + let localizationService = StateRegistration.currentEnvironment?.localizationService ?? LocalizationService.transient() let localizedValue = localizationService.string(for: key) self.init(localizedValue) diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index 346faeee9..c0d047668 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -11,9 +11,6 @@ struct OnAppearModifier: View { /// The content view. let content: Content - /// Unique token to track this view's lifecycle. - let token: String - /// The action to execute on first appearance. let action: () -> Void @@ -24,11 +21,13 @@ struct OnAppearModifier: View { extension OnAppearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - // Record appearance and execute action if first time - _ = context.environment.lifecycle!.recordAppear(token: token, action: action) + let scopedContext = context.withIdentityScope("lifecycle.appear") + _ = context.environment.lifecycle!.recordAppear( + identity: scopedContext.identity, + action: action + ) - // Render content - return TUIkit.renderToBuffer(content, context: context) + return TUIkit.renderToBuffer(content, context: scopedContext) } } @@ -39,9 +38,6 @@ struct OnDisappearModifier: View { /// The content view. let content: Content - /// Unique token to track this view's lifecycle. - let token: String - /// The action to execute when the view disappears. let action: () -> Void @@ -52,14 +48,13 @@ struct OnDisappearModifier: View { extension OnDisappearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { - // Register the disappear callback - context.environment.lifecycle!.registerDisappear(token: token, action: action) + let scopedContext = context.withIdentityScope("lifecycle.disappear") + let lifecycle = context.environment.lifecycle! - // Mark as visible in current render - _ = context.environment.lifecycle!.recordAppear(token: token, action: {}) + lifecycle.registerDisappear(identity: scopedContext.identity, action: action) + _ = lifecycle.recordAppear(identity: scopedContext.identity, action: {}) - // Render content - return TUIkit.renderToBuffer(content, context: context) + return TUIkit.renderToBuffer(content, context: scopedContext) } } @@ -72,11 +67,8 @@ struct TaskModifier: View { /// The content view. let content: Content - /// Unique token to track this view's lifecycle. - let token: String - /// The async task to execute. - let task: @Sendable () async -> Void + let task: @isolated(any) @Sendable () async -> Void /// Task priority. let priority: TaskPriority @@ -88,25 +80,22 @@ struct TaskModifier: View { extension TaskModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { + let scopedContext = context.withIdentityScope("lifecycle.task") let lifecycle = context.environment.lifecycle! - // Start task on first appearance - let isFirstAppear = !lifecycle.hasAppeared(token: token) + lifecycle.updateTask( + identity: scopedContext.identity, + id: MountedTaskID.value, + priority: priority, + operation: task + ) - _ = lifecycle.recordAppear(token: token) { - // Only start task on first appear - } - - if isFirstAppear { - lifecycle.startTask(token: token, priority: priority, operation: task) - } + return TUIkit.renderToBuffer(content, context: scopedContext) + } +} - // Register disappear callback to cancel task - lifecycle.registerDisappear(token: token) { [lifecycle] in - lifecycle.cancelTask(token: token) - } +// MARK: - Task Identity - // Render content - return TUIkit.renderToBuffer(content, context: context) - } +private enum MountedTaskID: Hashable { + case value } diff --git a/Sources/TUIkit/Rendering/ViewRenderer.swift b/Sources/TUIkit/Rendering/ViewRenderer.swift index c6378c231..8a836893a 100644 --- a/Sources/TUIkit/Rendering/ViewRenderer.swift +++ b/Sources/TUIkit/Rendering/ViewRenderer.swift @@ -62,8 +62,8 @@ extension ViewRenderer { /// Builds and renders a view inside a complete runtime render pass. /// - /// Constructing the root while hydration is active lets root-level - /// property wrappers bind to the same runtime as nested views. + /// Constructing the root with ambient environment context lets construction + /// APIs use the runtime. Dynamic properties bind at their final render path. func render( atRow row: Int = 1, column: Int = 1, diff --git a/Sources/TUIkit/State/AppStorage.swift b/Sources/TUIkit/State/AppStorage.swift index 4778116c4..a1d2337da 100644 --- a/Sources/TUIkit/State/AppStorage.swift +++ b/Sources/TUIkit/State/AppStorage.swift @@ -386,13 +386,13 @@ private extension AppStorageBox { } func bindToActiveRuntimeIfNeeded() { - guard let environment = StateRegistration.activeEnvironment else { return } + guard let environment = StateRegistration.currentEnvironment else { return } lock.lock() if runtimeStorage == nil { runtimeStorage = environment.storageBackend invalidationSink = environment.renderInvalidationSink - identity = StateRegistration.activeContext?.identity + identity = StateRegistration.currentContext?.identity } lock.unlock() } diff --git a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md index 3aa59bfb1..d2a123b02 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md @@ -41,7 +41,7 @@ This ensures that views which disappeared between frames don't leave stale handl ### Step 2: Begin Lifecycle and State Tracking -The `LifecycleManager` prepares for a new frame by clearing its `currentRenderTokens` set. The `StateStorage` clears its active identity set. The `RenderCache` begins a new render pass for cache hit/miss tracking. As views render, they add their tokens/identities to these sets. After rendering, the managers compare current and previous frames to detect which views appeared, disappeared, or had their state removed. +The `LifecycleManager` prepares for a new frame by clearing the set of structural runtime slots seen in the current pass. The `StateStorage` and observation registry clear their active identity sets. The `RenderCache` begins a new render pass for cache hit/miss tracking. As views render, they mark their stable slots or identities active. After rendering, the managers compare current and previous frames to detect which views appeared, disappeared, stopped being observed, or had their state removed. ### Step 3: Build Environment @@ -89,7 +89,7 @@ The context is passed down the view tree. Each view can create a modified copy f `app.body` is evaluated fresh each frame, producing a ``WindowGroup`` that wraps the root view. The `WindowGroup` implements `SceneRenderable` and bridges from the scene layer to the view layer. -> Note: Views are fully reconstructed on every frame. `@State` values survive because `State.init` self-hydrates from `StateStorage`: looking up the persistent value by the view's structural identity. +> Note: Views are fully reconstructed on every frame. `@State` values survive because the renderer binds each view's dynamic properties to `StateStorage` at the view's final structural identity before evaluating `body`. ### Step 6: Render View Tree @@ -124,10 +124,11 @@ The status bar renders in a separate pass but writes into the **same frame buffe ### Step 12: End Lifecycle and State Tracking -Three managers finalize the frame: +Four managers finalize the frame: -- The **`LifecycleManager`** compares the current frame's tokens with the previous frame's. Disappeared views (tokens present last frame but absent now) fire their `onDisappear` callbacks; their tokens are removed from the appeared set, allowing future `onAppear` if they return. +- The **`LifecycleManager`** compares the current frame's structural slots with the previous frame's. Disappeared views fire their `onDisappear` callbacks, cancel mounted tasks, and leave the appeared set so a future mount can trigger `onAppear` again. - The **`StateStorage`** performs garbage collection: any state whose view identity was not marked active during this render pass is removed. This prevents memory leaks from views that have been permanently removed. +- The **observation registry** removes identities that were not active in the completed render pass. Cache hits preserve existing registrations below skipped subtrees; callbacks from older generations and unmounted identities become inert. - The **`RenderCache`** removes inactive entries (subtrees no longer in the view tree) and optionally logs per-frame cache statistics. All state changes inside the lifecycle manager are `NSLock`-protected. Callbacks execute **outside** the lock to prevent deadlocks. @@ -178,13 +179,14 @@ func renderToBuffer(_ view: V, context: RenderContext) -> FrameBuffer { return renderable.renderToBuffer(context: context) } - // Priority 2: Composite: set up hydration context and recurse into body. - // @State.init self-hydrates from StateStorage during body evaluation. + // Priority 2: Composite: bind dynamic properties and recurse into body. if V.Body.self != Never.self { let childContext = context.withChildIdentity(type: V.Body.self) - // ... activate StateRegistration.activeContext ... - let body = view.body - // ... restore previous context, mark identity active ... + let body = StateRegistration.withHydration(of: view, context: context) { + // Observation is tracked for context.identity here. + view.body + } + // ... mark context.identity active ... return renderToBuffer(body, context: childContext) } @@ -295,14 +297,14 @@ More complex modifiers are full `View + Renderable` implementations that control ## Lifecycle Tracking -The `LifecycleManager` tracks view visibility across frames using unique tokens (UUIDs): +The `LifecycleManager` tracks view visibility across frames using stable slots derived from each modifier's structural identity. Reconstructing the same view hierarchy therefore addresses the same lifecycle state instead of creating a new token every frame. ### onAppear -The `OnAppearModifier` calls `lifecycle.recordAppear(token, action)` during rendering: +The `OnAppearModifier` calls `lifecycle.recordAppear(identity:action:)` during rendering: -- The token is added to `currentRenderTokens` (always) -- If the token has **never appeared before**: it's added to `appearedTokens` and the action fires +- The structural slot is marked visible in the current render pass +- If the slot has **never appeared before**: it is added to the appeared set and the action fires - If it **has** appeared before: the action does **not** fire (prevents repeated triggers) > Note: `onAppear` fires **synchronously** during the render traversal: not after the frame completes. This is because TUIkit uses single-pass rendering with no layout phase. @@ -311,18 +313,18 @@ The `OnAppearModifier` calls `lifecycle.recordAppear(token, action)` during rend The `OnDisappearModifier` does two things during rendering: -1. Registers its callback with `lifecycle.registerDisappear(token, action)` -2. Marks itself as visible with `lifecycle.recordAppear(token, {})` (empty action) +1. Registers its callback with `lifecycle.registerDisappear(identity:action:)` +2. Marks its structural slot visible with `lifecycle.recordAppear(identity:action:)` -The actual `onDisappear` callback fires in step 7 (end lifecycle tracking), **after** the entire view tree has rendered. +The actual `onDisappear` callback fires in step 12 (end lifecycle tracking), **after** the entire view tree has rendered. ### Task Lifecycle The `TaskModifier` (created by `.task()`) combines appearance tracking with async tasks: -1. On first appearance: starts a `Task` with the given priority and operation -2. Registers a disappear callback that cancels the task -3. If the view reappears, a new task starts +1. The first render at a structural task slot starts one `Task` with the given priority and operation +2. Unchanged reconstruction preserves the mounted task instead of restarting it +3. Removing the slot cancels the task; mounting it again starts a new task ## Output Optimization @@ -357,7 +359,7 @@ When a view is wrapped in `.equatable()`, the rendering system: 1. Looks up the cached ``FrameBuffer`` for this view's `ViewIdentity` 2. Compares the **current view value** with the cached snapshot via `Equatable.==` 3. Checks that the available **width and height** haven't changed -4. On **cache hit**: returns the cached buffer: the entire subtree is skipped +4. On **cache hit**: returns the cached buffer and preserves the subtree's State and Observation liveness without evaluating its body 5. On **cache miss**: renders normally and stores the result ```swift @@ -387,7 +389,8 @@ The runtime applies pending cache invalidations before rendering: | Trigger | Mechanism | |---------|-----------| | `@State` or `@AppStorage` change | Invalidates the owning view subtree | -| Observed model or language change | Requests a full runtime cache clear | +| Observed model change | Invalidates the structural subtree that read the dependency | +| Language change | Requests a full runtime cache clear | | Environment change | `RenderLoop` compares an `EnvironmentSnapshot` (palette ID + appearance ID) each frame and clears on mismatch | Each runtime owns its own `RenderCache`, so invalidation in one application cannot diff --git a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md index 0c93be0f5..371c246a2 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md @@ -118,6 +118,7 @@ This path is built automatically during rendering based on: - The view's type name - Its position among siblings (child index) - Conditional branches (`true`/`false` for `if`/`else`) +- Nested modifier and runtime slots ### Persistent State Storage @@ -125,8 +126,10 @@ All `@State` values live in the owning runtime's `StateStorage`, keyed by: - The view's structural identity - The property's declaration index within the view (0, 1, 2, ...) -When `@State var count = 0` is declared, the `init` checks if a persistent value already -exists for this position. If it does, the existing value is used instead of the default. +When `@State var count = 0` is constructed, it initially holds the declared default. +Immediately before the renderer evaluates that view's `body`, it binds each direct dynamic +property to the persistent location at the view's final structural identity. A reconstructed +view therefore reads the stored value instead of resetting to its default. ### Re-Render Trigger @@ -135,9 +138,13 @@ When a ``State`` value changes: 1. The `StateBox` sends a subtree invalidation to its runtime's `RenderInvalidationSink` 2. That runtime's `AppState` notifies the observer registered by `AppRunner` 3. The main loop re-evaluates `app.body` fresh: reconstructing all views -4. Each `@State.init` self-hydrates from the same runtime's `StateStorage` +4. The renderer rebinds each view's dynamic properties to the same runtime's `StateStorage` 5. The owning runtime clears the affected cached subtree and writes the new ``FrameBuffer`` +Observable dependencies read while evaluating `body` are tracked at the same committed +identity. Re-evaluating an identity replaces its active observation generation, so callbacks +from earlier view values become inert. Registrations are removed when their identity unmounts. + ### Garbage Collection Views that disappear from the tree (e.g., a conditional branch switches) have their state diff --git a/Sources/TUIkitCore/Rendering/ViewIdentity.swift b/Sources/TUIkitCore/Rendering/ViewIdentity.swift index 20acb91a9..e91c887ea 100644 --- a/Sources/TUIkitCore/Rendering/ViewIdentity.swift +++ b/Sources/TUIkitCore/Rendering/ViewIdentity.swift @@ -105,3 +105,39 @@ public extension ViewIdentity { descendant.path.hasPrefix(path + "/") || descendant.path.hasPrefix(path + "#") } } + +// MARK: - Runtime Identity Scopes + +package extension ViewIdentity { + /// Returns an identity for a stable runtime slot below this view. + /// + /// Modifier and effect implementations use scopes instead of allocation-time + /// tokens so reconstructed view values resolve to the same runtime record. + func scoped(_ scope: String) -> ViewIdentity { + ViewIdentity(path: "\(path)/@\(Self.encode(scope))") + } + + /// Returns a child identity derived from an explicit collection key. + /// + /// Unlike positional child identities, keyed identities remain unchanged + /// when siblings are inserted, deleted, or reordered. The key's reflected + /// representation is encoded so separators cannot alias structural paths. + func keyedChild(type: V.Type, key: ID) -> ViewIdentity { + let keyType = Self.encode(String(reflecting: ID.self)) + let keyValue = Self.encode(String(reflecting: key)) + return ViewIdentity( + path: "\(path)/\(String(describing: type))[@\(keyType):\(keyValue)]" + ) + } +} + +// MARK: - Private Helpers + +private extension ViewIdentity { + static func encode(_ value: String) -> String { + value.utf8.map { byte in + let encoded = String(byte, radix: 16, uppercase: true) + return encoded.count == 1 ? "0\(encoded)" : encoded + }.joined() + } +} diff --git a/Sources/TUIkitView/Core/EquatableView.swift b/Sources/TUIkitView/Core/EquatableView.swift index 31c3e82d6..0d80ea310 100644 --- a/Sources/TUIkitView/Core/EquatableView.swift +++ b/Sources/TUIkitView/Core/EquatableView.swift @@ -95,8 +95,8 @@ extension EquatableView: Renderable { contextWidth: context.availableWidth, contextHeight: context.availableHeight ) { - // Still need to run hydration for @State properties inside - // the cached subtree, so they stay active for GC. + // Still need to keep runtime records inside the cached subtree + // active even though its body is not evaluated. // But we skip the actual rendering work. markSubtreeActive(context: context) return cached @@ -120,13 +120,13 @@ extension EquatableView: Renderable { // MARK: - Private Helpers private extension EquatableView { - /// Marks the content's identity as active in StateStorage for GC. + /// 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 identities must still be marked active to prevent - /// StateStorage from garbage-collecting them. + /// Their state and Observation identities must still be marked active. func markSubtreeActive(context: RenderContext) { - context.environment.stateStorage!.markActive(context.identity) + context.environment.stateStorage!.markSubtreeActive(context.identity) + context.environment.observationRegistry?.markSubtreeActive(context.identity) } } diff --git a/Sources/TUIkitView/Environment/EnvironmentProperty.swift b/Sources/TUIkitView/Environment/EnvironmentProperty.swift index 597a12dec..0230b95b5 100644 --- a/Sources/TUIkitView/Environment/EnvironmentProperty.swift +++ b/Sources/TUIkitView/Environment/EnvironmentProperty.swift @@ -96,7 +96,7 @@ public struct Environment { /// Reads from the active render environment if available, /// otherwise returns the default value. public var wrappedValue: Value { - let env = StateRegistration.activeEnvironment ?? EnvironmentValues() + let env = StateRegistration.currentEnvironment ?? EnvironmentValues() switch strategy { case .keyPath(let keyPath): return env[keyPath: keyPath] diff --git a/Sources/TUIkitView/Environment/ViewServiceEnvironment.swift b/Sources/TUIkitView/Environment/ViewServiceEnvironment.swift index 5d196baa1..d6a7604e6 100644 --- a/Sources/TUIkitView/Environment/ViewServiceEnvironment.swift +++ b/Sources/TUIkitView/Environment/ViewServiceEnvironment.swift @@ -27,6 +27,13 @@ private struct RenderInvalidationSinkKey: EnvironmentKey { static let defaultValue: (any RenderInvalidationSink)? = nil } +// MARK: - Observation Registry + +/// EnvironmentKey for identity-bound Observation registrations. +private struct ObservationRegistryKey: EnvironmentKey { + static let defaultValue: ObservationRegistry? = nil +} + // MARK: - EnvironmentValues Extensions extension EnvironmentValues { @@ -48,4 +55,10 @@ extension EnvironmentValues { get { self[RenderInvalidationSinkKey.self] } set { self[RenderInvalidationSinkKey.self] = newValue } } + + /// Registry that binds Observation callbacks to structural identities. + package var observationRegistry: ObservationRegistry? { + get { self[ObservationRegistryKey.self] } + set { self[ObservationRegistryKey.self] = newValue } + } } diff --git a/Sources/TUIkitView/Rendering/ChildInfo.swift b/Sources/TUIkitView/Rendering/ChildInfo.swift index 79e967704..425c0822a 100644 --- a/Sources/TUIkitView/Rendering/ChildInfo.swift +++ b/Sources/TUIkitView/Rendering/ChildInfo.swift @@ -188,7 +188,7 @@ public func measureChild(_ view: V, proposal: ProposedSize, context: Re // injection) lives in renderToBuffer, not in body. They fall through // to the render-to-measure fallback below, which runs the full pipeline. if !(view is Renderable), V.Body.self != Never.self { - let body = StateRegistration.withHydration(context: context) { + let body = StateRegistration.withHydration(of: view, context: context) { view.body } return measureChild(body, proposal: proposal, context: context) diff --git a/Sources/TUIkitView/Rendering/RenderContext.swift b/Sources/TUIkitView/Rendering/RenderContext.swift index 664ca601d..202f4e2a9 100644 --- a/Sources/TUIkitView/Rendering/RenderContext.swift +++ b/Sources/TUIkitView/Rendering/RenderContext.swift @@ -126,6 +126,31 @@ public struct RenderContext { return copy } + /// Creates a new context for a stable runtime slot below this view. + /// + /// - Parameter scope: Framework-defined modifier or effect scope. + /// - Returns: A new context with the scoped identity. + package func withIdentityScope(_ scope: String) -> Self { + var copy = self + copy.identity = identity.scoped(scope) + return copy + } + + /// Creates a new context for a child identified by an explicit key. + /// + /// Keyed child paths do not depend on sibling position, allowing state and + /// runtime records to follow a child across collection reorder operations. + /// + /// - Parameters: + /// - type: The child view's type. + /// - key: The child's explicit collection key. + /// - Returns: A new context with the keyed child identity. + package func withKeyedChildIdentity(type: V.Type, key: ID) -> Self { + var copy = self + copy.identity = identity.keyedChild(type: type, key: key) + return copy + } + /// Creates a new context with a different available width. /// /// Used by layout containers (e.g., NavigationSplitView) to constrain diff --git a/Sources/TUIkitView/Rendering/Renderable.swift b/Sources/TUIkitView/Rendering/Renderable.swift index 6c9d05a8f..33a9fe1b6 100644 --- a/Sources/TUIkitView/Rendering/Renderable.swift +++ b/Sources/TUIkitView/Rendering/Renderable.swift @@ -165,26 +165,31 @@ public func renderToBuffer(_ view: V, context: RenderContext) -> FrameB return renderable.renderToBuffer(context: context) } - // Priority 2: Composite view — set up hydration context and recurse into body. + // Priority 2: Composite view — bind dynamic properties and recurse into body. // - // Before evaluating `body`, we activate the hydration context so that any - // @State properties created during body evaluation self-hydrate from StateStorage. - // - // For the view's OWN @State properties: these were already hydrated when the - // view was constructed (either via self-hydrating init if activeContext was set, - // or via the parent's body evaluation context). The context here is for CHILDREN - // that will be constructed inside this view's body. + // The owning view's dynamic properties bind at its final structural identity + // before body evaluation. Ambient environment context remains active while + // child values are constructed inside the body. if V.Body.self != Never.self { let childContext = context.withChildIdentity(type: V.Body.self) let invalidationSink = context.environment.renderInvalidationSink // Wrap body evaluation in observation tracking so that any @Observable // property accessed during body triggers a re-render when mutated. - let body = StateRegistration.withHydration(context: context) { - withObservationTracking { - view.body - } onChange: { - invalidationSink?.invalidate(.all) + let body = StateRegistration.withHydration(of: view, context: context) { + if let observationRegistry = context.environment.observationRegistry { + observationRegistry.track( + identity: context.identity, + invalidationSink: invalidationSink + ) { + view.body + } + } else { + withObservationTracking { + view.body + } onChange: { + invalidationSink?.invalidate(.all) + } } } diff --git a/Sources/TUIkitView/State/ObservationRegistry.swift b/Sources/TUIkitView/State/ObservationRegistry.swift new file mode 100644 index 000000000..c5a873566 --- /dev/null +++ b/Sources/TUIkitView/State/ObservationRegistry.swift @@ -0,0 +1,125 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ObservationRegistry.swift +// +// Created by LAYERED.work +// License: MIT + +import Observation +import TUIkitCore + +/// Tracks Observation registrations by committed structural identity. +/// +/// Observation callbacks are one-shot and cannot be explicitly detached. Each +/// render therefore replaces the active generation for an identity. Callbacks +/// from older generations or unmounted identities become inert and retain no +/// runtime through their weak references. +package final class ObservationRegistry: Sendable { + private struct RegistryState: Sendable { + var nextGeneration: UInt64 = 0 + var generations: [ViewIdentity: UInt64] = [:] + var activeIdentities: Set = [] + } + + private let state = Lock(initialState: RegistryState()) + + /// Creates an empty observation registry. + package init() {} + + /// Number of live identity registrations for tests and diagnostics. + package var count: Int { + state.withLock { $0.generations.count } + } + + /// Whether the registry currently retains no identity registrations. + package var isEmpty: Bool { + state.withLock { $0.generations.isEmpty } + } +} + +// MARK: - Render Pass Lifecycle + +package extension ObservationRegistry { + /// Starts liveness tracking for a render pass. + func beginRenderPass() { + state.withLock { registry in + registry.activeIdentities.removeAll(keepingCapacity: true) + } + } + + /// Keeps existing registrations below a cached subtree mounted. + func markSubtreeActive(_ root: ViewIdentity) { + state.withLock { registry in + let mountedIdentities = registry.generations.keys.filter { identity in + identity == root || root.isAncestor(of: identity) + } + registry.activeIdentities.formUnion(mountedIdentities) + } + } + + /// Removes registrations for identities absent from the completed pass. + func endRenderPass() { + state.withLock { registry in + registry.generations = registry.generations.filter { + registry.activeIdentities.contains($0.key) + } + } + } + + /// Removes every registration during runtime shutdown. + func reset() { + state.withLock { registry in + registry.nextGeneration = 0 + registry.generations.removeAll() + registry.activeIdentities.removeAll() + } + } +} + +// MARK: - Tracking + +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. + @MainActor + func track( + identity: ViewIdentity, + invalidationSink: (any RenderInvalidationSink)?, + _ body: () -> Result + ) -> Result { + let generation = state.withLock { registry -> UInt64 in + registry.nextGeneration &+= 1 + let generation = registry.nextGeneration + registry.generations[identity] = generation + registry.activeIdentities.insert(identity) + return generation + } + let weakSink = WeakInvalidationSink(invalidationSink) + + return withObservationTracking { + body() + } onChange: { [weak self, weakSink] in + guard self?.isCurrent(generation, for: identity) == true else { return } + weakSink.value?.invalidate(.subtree(identity)) + } + } +} + +// MARK: - Private Helpers + +private extension ObservationRegistry { + func isCurrent(_ generation: UInt64, for identity: ViewIdentity) -> Bool { + state.withLock { registry in + registry.generations[identity] == generation + } + } +} + +private final class WeakInvalidationSink: @unchecked Sendable { + weak var value: (any RenderInvalidationSink)? + + init(_ value: (any RenderInvalidationSink)?) { + self.value = value + } +} diff --git a/Sources/TUIkitView/State/State.swift b/Sources/TUIkitView/State/State.swift index d4b10185c..be9171d2f 100644 --- a/Sources/TUIkitView/State/State.swift +++ b/Sources/TUIkitView/State/State.swift @@ -172,12 +172,11 @@ extension AppState: RenderInvalidationSink { // MARK: - Hydration Context -/// The active render context used by `@State` during self-hydration. +/// The render context used to bind dynamic properties to runtime storage. /// -/// Set by `renderToBuffer(_:context:)` before evaluating a composite view's `body`, -/// and cleared immediately after. Provides the view identity and state storage -/// that `@State.init` needs to retrieve or create persistent state. -public struct HydrationContext { +/// Created by `renderToBuffer(_:context:)` after a view reaches its final +/// structural position and before evaluating that view's `body`. +public struct HydrationContext: Sendable { /// The current view's structural identity. public let identity: ViewIdentity @@ -199,38 +198,62 @@ public struct HydrationContext { } } +// MARK: - Runtime Dynamic Property + +/// Internal binding contract for property wrappers that need their committed +/// view identity before `body` is evaluated. +package protocol RuntimeDynamicProperty { + /// Binds the property to one stable slot on the final structural identity. + func bind(to context: HydrationContext, propertyIndex: Int) +} + // MARK: - State Registration -/// Framework-internal state for `@State` self-hydration during rendering. +/// Framework-internal context for dynamic-property and environment evaluation. /// -/// When `renderToBuffer(_:context:)` is about to evaluate a composite view's `body`, -/// it sets ``activeContext`` and resets ``counter`` to 0. Each `@State.init` that runs -/// during `body` evaluation checks ``activeContext``: -/// -/// - **Non-nil:** Claims the next property index from ``counter`` and retrieves a -/// persistent `StateBox` from `StateStorage`. -/// - **Nil:** Creates a local `StateBox` (pre-render or outside the render tree). -/// -/// This is safe because TUIKit runs on a single thread — no concurrent access. +/// The renderer first reflects the owning view's dynamic properties and binds +/// them to its final structural identity. Ambient context remains available +/// while evaluating `body` for environment-backed construction APIs. public enum StateRegistration { + /// Dynamically scoped hydration context used by production rendering. + @TaskLocal package static var runtimeContext: HydrationContext? + + /// Dynamically scoped environment used by production rendering. + @TaskLocal package static var runtimeEnvironment: EnvironmentValues? + /// The active hydration context, set during composite view body evaluation. /// - /// - Important: Must be set before and cleared after each `body` call. - /// Nested composite views save/restore the previous context. + /// Legacy mutable fallback retained for source compatibility. Production + /// rendering uses `runtimeContext` instead. nonisolated(unsafe) public static var activeContext: HydrationContext? - /// The current property index, incremented by each `@State` during hydration. + /// 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. - /// Set alongside ``activeContext`` in `renderToBuffer(_:context:)`. + /// 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. + package static var currentContext: HydrationContext? { + runtimeContext ?? activeContext + } + + /// Current dynamically scoped environment, including the compatibility fallback. + package static var currentEnvironment: EnvironmentValues? { + runtimeEnvironment ?? activeEnvironment + } + /// Evaluates a closure with a hydration context active. /// - /// Sets up `activeContext`, `counter`, and `activeEnvironment` before - /// calling the closure, then restores the previous state. This pattern + /// Installs task-local runtime context and environment values while + /// calling the closure, then restores the enclosing scope. This pattern /// is needed whenever `view.body` is evaluated outside the normal /// `renderToBuffer` dispatch (e.g., in `measureChild`). /// @@ -242,27 +265,58 @@ public enum StateRegistration { context: RenderContext, _ block: () -> R ) -> R { - let previousContext = activeContext - let previousCounter = counter - let previousEnvironment = activeEnvironment + withHydration(owner: nil, context: context, block) + } + + /// Evaluates a view or app body after binding its dynamic properties to + /// the final structural identity supplied by the renderer. + package static func withHydration( + of owner: Owner, + context: RenderContext, + _ block: () -> R + ) -> R { + withHydration(owner: owner, context: context, block) + } + + /// Binds direct dynamic-property fields for tests and specialized runtime paths. + package static func bindDynamicProperties( + in owner: Owner, + context: HydrationContext + ) { + var propertyIndex = 0 + var mirror: Mirror? = Mirror(reflecting: owner) + + while let currentMirror = mirror { + for child in currentMirror.children { + guard let property = child.value as? any RuntimeDynamicProperty else { continue } + property.bind(to: context, propertyIndex: propertyIndex) + propertyIndex += 1 + } + mirror = currentMirror.superclassMirror + } + } - activeContext = context.environment.stateStorage.map { + private static func withHydration( + owner: Any?, + context: RenderContext, + _ block: () -> R + ) -> R { + let hydrationContext = context.environment.stateStorage.map { HydrationContext( identity: context.identity, storage: $0, invalidationSink: context.environment.renderInvalidationSink ) } - counter = 0 - activeEnvironment = context.environment - let result = block() - - activeContext = previousContext - counter = previousCounter - activeEnvironment = previousEnvironment - - return result + return $runtimeContext.withValue(hydrationContext) { + $runtimeEnvironment.withValue(context.environment) { + if let owner, let hydrationContext { + bindDynamicProperties(in: owner, context: hydrationContext) + } + return block() + } + } } } @@ -354,28 +408,19 @@ public struct Binding { /// /// # Render Integration /// -/// `@State` uses **self-hydrating init**: when `@State.init` runs while a -/// render context is active (`StateRegistration.activeContext`), it claims -/// the next property index and retrieves (or creates) a persistent `StateBox` -/// from `StateStorage`. -/// -/// The render loop sets the active context **before** evaluating `App.body`, -/// so views constructed inside `WindowGroup { ... }` closures self-hydrate -/// immediately. For nested composite views, `renderToBuffer(_:context:)` -/// saves and restores the context around each `body` evaluation. +/// Immediately before a view's `body` is evaluated, the renderer binds each +/// `@State` property to a persistent `StateBox` using the view's final +/// structural identity and the property's declaration slot. /// -/// State is keyed by `ViewIdentity` and property index, ensuring values -/// survive view reconstruction across render passes. +/// Binding after structural traversal prevents a child constructed in its +/// parent's body from claiming the parent's identity. State therefore survives +/// reconstruction while independent siblings retain independent storage. /// /// Mutations signal re-renders through the owning runtime's invalidation sink. @propertyWrapper public struct State { - /// The backing storage box for this state value. - /// - /// Either a local box (when no render context is active) or a persistent - /// box from `StateStorage` (during rendering). Since `StateBox` is a - /// reference type, mutations through `nonmutating set` are visible everywhere. - private let box: StateBox + /// Stable indirection that can adopt the box for the committed view identity. + private let location: StateLocation /// The default value provided at init time. /// @@ -385,53 +430,65 @@ public struct State { /// The current state value. public var wrappedValue: Value { - get { - bindToActiveRuntime() - return box.value - } + get { location.box.value } nonmutating set { - bindToActiveRuntime() - box.value = newValue + location.box.value = newValue } } /// A binding to the state value. public var projectedValue: Binding { - bindToActiveRuntime() return Binding( - get: { self.box.value }, - set: { self.box.value = $0 } + get: { self.location.box.value }, + set: { self.location.box.value = $0 } ) } /// Creates a state with an initial value. /// - /// If a render context is active (`StateRegistration.activeContext`), - /// the state self-hydrates: it claims a property index and retrieves - /// or creates a persistent `StateBox` from `StateStorage`. - /// - /// Otherwise, a local `StateBox` is created with the default value. + /// The wrapper starts with local storage. The renderer replaces that storage + /// with the persistent box for the committed view identity before `body` + /// evaluation. /// /// - Parameter wrappedValue: The initial/default value. public init(wrappedValue: Value) { self.defaultValue = wrappedValue - if let context = StateRegistration.activeContext { - let index = StateRegistration.counter - StateRegistration.counter += 1 - let key = StateStorage.StateKey(identity: context.identity, propertyIndex: index) - self.box = context.storage.storage(for: key, default: wrappedValue) - self.box.bind(identity: context.identity, invalidationSink: context.invalidationSink) - } else { - self.box = StateBox(wrappedValue) - } + self.location = StateLocation(defaultValue: wrappedValue) + } +} + +// MARK: - Runtime Binding + +extension State: RuntimeDynamicProperty { + package func bind(to context: HydrationContext, propertyIndex: Int) { + location.bind(to: context, propertyIndex: propertyIndex) } } -// MARK: - Private Helpers +// MARK: - State Location + +private final class StateLocation { + let defaultValue: Value + var box: StateBox + private weak var storage: StateStorage? + private var key: StateStorage.StateKey? + + init(defaultValue: Value) { + self.defaultValue = defaultValue + self.box = StateBox(defaultValue) + } -private extension State { - func bindToActiveRuntime() { - guard let context = StateRegistration.activeContext else { return } + func bind(to context: HydrationContext, propertyIndex: Int) { + let key = StateStorage.StateKey( + identity: context.identity, + propertyIndex: propertyIndex + ) + + if self.key != key || storage !== context.storage { + box = context.storage.storage(for: key, default: defaultValue) + storage = context.storage + self.key = key + } box.bind(identity: context.identity, invalidationSink: context.invalidationSink) } } diff --git a/Sources/TUIkitView/State/StateStorage.swift b/Sources/TUIkitView/State/StateStorage.swift index 00a4384ed..ddc4bf2da 100644 --- a/Sources/TUIkitView/State/StateStorage.swift +++ b/Sources/TUIkitView/State/StateStorage.swift @@ -80,6 +80,11 @@ public final class StateStorage: @unchecked Sendable { /// The number of stored state entries (for testing/debugging). public var count: Int { values.count } + + /// Structural identities currently owning stored values. + package var storedIdentities: Set { + Set(values.keys.map(\.identity)) + } } // MARK: - Internal API @@ -127,6 +132,18 @@ extension StateStorage { activeIdentities.insert(identity) } + /// Keeps state records below a cached subtree active without traversing it. + package func markSubtreeActive(_ root: ViewIdentity) { + let storedIdentities = values.keys.lazy.map(\.identity) + let trackedIdentities = trackedValues.keys.lazy.map(\.identity) + activeIdentities.formUnion(storedIdentities.filter { identity in + identity == root || root.isAncestor(of: identity) + }) + activeIdentities.formUnion(trackedIdentities.filter { identity in + identity == root || root.isAncestor(of: identity) + }) + } + // MARK: - onChange Tracking /// Claims the next `onChange` property index for the given identity. diff --git a/Tests/TUIkitTests/EquatableViewTests.swift b/Tests/TUIkitTests/EquatableViewTests.swift index 7e891cc7c..b6e86636e 100644 --- a/Tests/TUIkitTests/EquatableViewTests.swift +++ b/Tests/TUIkitTests/EquatableViewTests.swift @@ -4,6 +4,7 @@ // Created by LAYERED.work // License: MIT +import Observation import Testing @testable import TUIkit @@ -17,6 +18,47 @@ private struct LabelView: View, Equatable { } } +@Observable +private final class EquatableObservationModel { + var value = 0 +} + +private struct ObservedLabelView: View { + let model: EquatableObservationModel + + var body: some View { + Text("Value: \(model.value)") + } +} + +extension ObservedLabelView: @preconcurrency Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.model === rhs.model + } +} + +private struct CachedStateContainer: View { + var body: some View { + VStack { + CachedStateLeaf() + } + } +} + +extension CachedStateContainer: @preconcurrency Equatable { + static func == (_: Self, _: Self) -> Bool { + true + } +} + +private struct CachedStateLeaf: View { + @State private var value = 42 + + var body: some View { + Text("Value: \(value)") + } +} + @MainActor @Suite("EquatableView Tests", .serialized) struct EquatableViewTests { @@ -73,6 +115,55 @@ struct EquatableViewTests { #expect(context.environment.renderCache!.count == 1) } + @Test("Cache hit keeps observed dependencies mounted") + func cacheHitKeepsObservationMounted() { + let tuiContext = TUIContext() + let model = EquatableObservationModel() + let context = RenderContext( + availableWidth: 80, + availableHeight: 24, + environment: tuiContext.environmentValues(), + identity: ViewIdentity(path: "Root/ObservedCache") + ) + let view = EquatableView(content: ObservedLabelView(model: model)) + + tuiContext.beginRenderPass() + _ = renderToBuffer(view, context: context) + tuiContext.endRenderPass() + #expect(tuiContext.observationRegistry.count == 1) + + tuiContext.beginRenderPass() + _ = renderToBuffer(view, context: context) + tuiContext.endRenderPass() + #expect(tuiContext.observationRegistry.count == 1) + + model.value = 1 + #expect(tuiContext.appState.needsRender) + } + + @Test("Cache hit keeps descendant State mounted") + func cacheHitKeepsDescendantStateMounted() { + let tuiContext = TUIContext() + let context = RenderContext( + availableWidth: 80, + availableHeight: 24, + environment: tuiContext.environmentValues(), + identity: ViewIdentity(path: "Root/StateCache") + ) + let view = EquatableView(content: CachedStateContainer()) + + tuiContext.beginRenderPass() + _ = renderToBuffer(view, context: context) + tuiContext.endRenderPass() + #expect(tuiContext.stateStorage.count == 1) + + tuiContext.beginRenderPass() + _ = renderToBuffer(view, context: context) + tuiContext.endRenderPass() + + #expect(tuiContext.stateStorage.count == 1) + } + // MARK: - Cache Miss on Changed Content @Test("Changed content causes cache miss and re-render") diff --git a/Tests/TUIkitTests/LifecycleManagerTests.swift b/Tests/TUIkitTests/LifecycleManagerTests.swift index a466dd377..f653407c1 100644 --- a/Tests/TUIkitTests/LifecycleManagerTests.swift +++ b/Tests/TUIkitTests/LifecycleManagerTests.swift @@ -194,6 +194,24 @@ struct LifecycleManagerDisappearTests { manager.endRenderPass() #expect(called == false) // Callback was unregistered } + + @Test("Repeated registration replaces one callback and unmount releases it") + func repeatedRegistrationDoesNotGrow() { + let manager = LifecycleManager() + + manager.beginRenderPass() + _ = manager.recordAppear(token: "view-1") {} + manager.registerDisappear(token: "view-1") {} + manager.registerDisappear(token: "view-1") {} + manager.endRenderPass() + + #expect(manager.disappearCallbackCount == 1) + + manager.beginRenderPass() + manager.endRenderPass() + + #expect(manager.disappearCallbackCount == 0) + } } // MARK: - Task Storage Tests @@ -202,6 +220,101 @@ struct LifecycleManagerDisappearTests { @Suite("LifecycleManager Task Tests") struct LifecycleManagerTaskTests { + @Test("Unchanged structural task stays mounted and ID changes replace it", .timeLimit(.minutes(1))) + func structuralTaskIdentity() async { + let manager = LifecycleManager() + let identity = ViewIdentity(path: "Root/@task") + let events = TraceRecorder() + let firstStarted = AsyncSignal() + let firstRelease = AsyncSignal() + let firstCompleted = AsyncSignal() + let replacementStarted = AsyncSignal() + + manager.beginRenderPass() + let started = manager.updateTask(identity: identity, id: 1, priority: .medium) { + events.record("first-started") + firstStarted.signal() + await firstRelease.wait() + events.record("first-cancelled:\(Task.isCancelled)") + firstCompleted.signal() + } + manager.endRenderPass() + await firstStarted.wait() + + manager.beginRenderPass() + let preserved = manager.updateTask(identity: identity, id: 1, priority: .medium) { + events.record("unexpected-restart") + } + manager.endRenderPass() + + #expect(started) + #expect(preserved == false) + #expect(manager.taskCount == 1) + + manager.beginRenderPass() + let replaced = manager.updateTask(identity: identity, id: 2, priority: .medium) { + events.record("replacement-started") + replacementStarted.signal() + } + manager.endRenderPass() + + firstRelease.signal() + await firstCompleted.wait() + await replacementStarted.wait() + + #expect(replaced) + #expect(events.snapshot().contains("unexpected-restart") == false) + #expect(events.snapshot().contains("first-cancelled:true")) + #expect(manager.taskCount == 1) + } + + @Test("Unmount cancels and releases a structural task", .timeLimit(.minutes(1))) + func structuralTaskUnmount() async { + let manager = LifecycleManager() + let identity = ViewIdentity(path: "Root/@task") + let started = AsyncSignal() + let release = AsyncSignal() + let completed = AsyncSignal() + let events = TraceRecorder() + + manager.beginRenderPass() + manager.updateTask(identity: identity, id: 1, priority: .medium) { + started.signal() + await release.wait() + events.record("cancelled:\(Task.isCancelled)") + completed.signal() + } + manager.endRenderPass() + await started.wait() + + manager.beginRenderPass() + manager.endRenderPass() + release.signal() + await completed.wait() + + #expect(events.snapshot() == ["cancelled:true"]) + #expect(manager.taskCount == 0) + } + + @Test("Structural task preserves inherited MainActor isolation", .timeLimit(.minutes(1))) + func structuralTaskActorIsolation() async { + let manager = LifecycleManager() + let identity = ViewIdentity(path: "Root/@task") + let started = AsyncSignal() + let state = MainActorTaskState() + + manager.beginRenderPass() + manager.updateTask(identity: identity, id: 1, priority: .medium) { + MainActor.preconditionIsolated() + state.value = 42 + started.signal() + } + manager.endRenderPass() + await started.wait() + + #expect(state.value == 42) + } + @Test("startTask runs its operation", .timeLimit(.minutes(1))) func startTask() async { let manager = LifecycleManager() @@ -333,3 +446,8 @@ private enum LifecycleTaskEvent: Hashable, Sendable { case started(String) case completed(String, wasCancelled: Bool) } + +@MainActor +private final class MainActorTaskState { + var value = 0 +} diff --git a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift index 6cbecb680..dc160283c 100644 --- a/Tests/TUIkitTests/RuntimeCharacterizationTests.swift +++ b/Tests/TUIkitTests/RuntimeCharacterizationTests.swift @@ -120,6 +120,31 @@ struct RuntimeCharacterizationTests { harness.unmount() } + @Test("View task updates State and does not restart on reconstruction", .timeLimit(.minutes(1))) + func taskUpdatesStateAcrossReconstruction() async { + let harness = RuntimeCharacterizationHarness() + let trace = harness.trace + let started = AsyncSignal() + + let initial = harness.render { + TaskStateCharacterizationView(trace: trace, started: started) + } + + #expect(initial.ansiStrippedLines == ["task has not run"]) + + await started.wait() + + let updated = harness.render { + TaskStateCharacterizationView(trace: trace, started: started) + } + let startCount = trace.snapshot().filter { $0 == .task("state task started") }.count + + #expect(updated.ansiStrippedLines == ["task has run"]) + #expect(startCount == 1) + + harness.unmount() + } + @Test("Observation changes can be traced deterministically", .timeLimit(.minutes(1))) func observationTrace() async { let harness = RuntimeCharacterizationHarness() @@ -170,8 +195,8 @@ struct RuntimeCharacterizationTests { } } - @Test("Reconstructed lifecycle identity remains characterized for issue #10") - func knownReconstructedLifecycleIdentityDefect() { + @Test("Reconstructed lifecycle modifier keeps one mounted identity") + func reconstructedLifecycleIdentity() { let harness = RuntimeCharacterizationHarness() let trace = harness.trace @@ -180,24 +205,33 @@ struct RuntimeCharacterizationTests { .onAppear { trace.record(.lifecycle("reconstructed appear")) } + .onDisappear { + trace.record(.lifecycle("reconstructed disappear")) + } } _ = harness.render { Text("mounted") .onAppear { trace.record(.lifecycle("reconstructed appear")) } + .onDisappear { + trace.record(.lifecycle("reconstructed disappear")) + } } let appearanceCount = trace.snapshot().filter { $0 == .lifecycle("reconstructed appear") }.count - withKnownIssue("Issue #10: lifecycle UUIDs change when a view is reconstructed") { - #expect(appearanceCount == 1) - } matching: { issue in - guard case .expectationFailed = issue.kind else { return false } - return true - } + #expect(appearanceCount == 1) + #expect(trace.snapshot().contains(.lifecycle("reconstructed disappear")) == false) + + harness.unmount() + + let disappearanceCount = trace.snapshot().filter { + $0 == .lifecycle("reconstructed disappear") + }.count + #expect(disappearanceCount == 1) } @Test("Default runtime render caches are isolated") @@ -247,6 +281,22 @@ private struct StatefulCharacterizationView: View { } } +private struct TaskStateCharacterizationView: View { + @State private var taskHasRun = false + + let trace: TraceRecorder + let started: AsyncSignal + + var body: some View { + Text("task has \(taskHasRun ? "" : "not ")run") + .task { + taskHasRun = true + trace.record(.task("state task started")) + started.signal() + } + } +} + private struct ANSICharacterizationView: View, Renderable { var body: Never { fatalError("ANSICharacterizationView renders via Renderable") diff --git a/Tests/TUIkitTests/StateHydrationIntegrationTests.swift b/Tests/TUIkitTests/StateHydrationIntegrationTests.swift index 1110d4476..88318693d 100644 --- a/Tests/TUIkitTests/StateHydrationIntegrationTests.swift +++ b/Tests/TUIkitTests/StateHydrationIntegrationTests.swift @@ -60,6 +60,8 @@ struct StateHydrationIntegrationTests { let lines = buffer.lines.joined() #expect(lines.contains("A:10")) #expect(lines.contains("B:20")) + #expect(tuiContext.stateStorage.storedIdentities.count == 2) + #expect(tuiContext.stateStorage.storedIdentities.contains(context.identity) == false) } } diff --git a/Tests/TUIkitTests/TUIContextTests.swift b/Tests/TUIkitTests/TUIContextTests.swift index 76dfb1c4f..52e201012 100644 --- a/Tests/TUIkitTests/TUIContextTests.swift +++ b/Tests/TUIkitTests/TUIContextTests.swift @@ -115,10 +115,43 @@ struct TUIContextTests { firstContext.applyPendingRenderInvalidations() - #expect(firstContext.renderCache.stats.clears == 1) + #expect(firstContext.renderCache.stats.subtreeClears == 1) #expect(secondContext.renderCache.stats.clears == 0) } + @Test("Observation registrations stay bounded and unmount with their identity") + func observationRegistrationLifecycle() { + let context = TUIContext() + let model = RuntimeObservationModel() + let renderContext = RenderContext( + availableWidth: 20, + availableHeight: 1, + tuiContext: context, + identity: ViewIdentity(path: "observed") + ) + + for _ in 0..<10 { + context.beginRenderPass() + _ = renderToBuffer( + RuntimeObservationView(model: model), + context: renderContext + ) + context.endRenderPass() + } + + #expect(context.observationRegistry.count == 1) + + context.beginRenderPass() + context.endRenderPass() + + #expect(context.observationRegistry.isEmpty) + + context.appState.didRender() + model.value = 1 + + #expect(context.appState.needsRender == false) + } + @Test("Application services are isolated per runtime") func applicationServicesAreIsolatedPerRuntime() { let firstContext = TUIContext() @@ -313,6 +346,7 @@ struct TUIContextTests { ) #expect(renderContext.environment.stateStorage === tuiContext.stateStorage) + #expect(renderContext.environment.observationRegistry === tuiContext.observationRegistry) #expect(renderContext.environment.lifecycle === tuiContext.lifecycle) #expect(renderContext.environment.keyEventDispatcher === tuiContext.keyEventDispatcher) #expect(renderContext.environment.renderCache === tuiContext.renderCache) diff --git a/Tests/TUIkitViewTests/ObservationRegistryTests.swift b/Tests/TUIkitViewTests/ObservationRegistryTests.swift new file mode 100644 index 000000000..b7d86b8a7 --- /dev/null +++ b/Tests/TUIkitViewTests/ObservationRegistryTests.swift @@ -0,0 +1,137 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ObservationRegistryTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Observation +import Testing +import TUIkitCore + +@testable import TUIkitView + +@MainActor +@Suite("Observation Registry Tests") +struct ObservationRegistryTests { + @Test("Repeated evaluation keeps one effective identity registration") + func repeatedEvaluationDoesNotGrow() { + let registry = ObservationRegistry() + let sink = RecordingInvalidationSink() + let model = ObservationModel() + let identity = ViewIdentity(path: "Root/Observed") + + registry.beginRenderPass() + registry.track(identity: identity, invalidationSink: sink) { + _ = model.value + } + registry.endRenderPass() + + registry.beginRenderPass() + registry.track(identity: identity, invalidationSink: sink) { + _ = model.value + } + registry.endRenderPass() + + #expect(registry.count == 1) + + model.value = 1 + + #expect(sink.invalidatedSubtrees == [identity]) + } + + @Test("Cached subtree keeps descendant registrations mounted") + func cachedSubtreeKeepsDescendantsMounted() { + let registry = ObservationRegistry() + let sink = RecordingInvalidationSink() + let model = ObservationModel() + let root = ViewIdentity(path: "Root/Cached") + let descendant = ViewIdentity(path: "Root/Cached/Observed") + let sibling = ViewIdentity(path: "Root/Sibling") + + registry.beginRenderPass() + registry.track(identity: descendant, invalidationSink: sink) { + _ = model.value + } + registry.track(identity: sibling, invalidationSink: sink) { + _ = model.value + } + registry.endRenderPass() + + registry.beginRenderPass() + registry.markSubtreeActive(root) + registry.endRenderPass() + + #expect(registry.count == 1) + + model.value = 1 + + #expect(sink.invalidatedSubtrees == [descendant]) + } + + @Test("Unmount removes registration and makes pending callback inert") + func unmountRemovesRegistration() { + let registry = ObservationRegistry() + let sink = RecordingInvalidationSink() + let model = ObservationModel() + let identity = ViewIdentity(path: "Root/Observed") + + registry.beginRenderPass() + registry.track(identity: identity, invalidationSink: sink) { + _ = model.value + } + registry.endRenderPass() + + registry.beginRenderPass() + registry.endRenderPass() + + #expect(registry.isEmpty) + + model.value = 1 + + #expect(sink.invalidatedSubtrees.isEmpty) + } + + @Test("Reset releases every identity registration") + func resetReleasesRegistrations() { + let registry = ObservationRegistry() + let sink = RecordingInvalidationSink() + let model = ObservationModel() + + registry.beginRenderPass() + for index in 0..<3 { + registry.track( + identity: ViewIdentity(path: "Root/\(index)"), + invalidationSink: sink + ) { + _ = model.value + } + } + registry.endRenderPass() + + #expect(registry.count == 3) + + registry.reset() + + #expect(registry.isEmpty) + } +} + +@Observable +private final class ObservationModel { + var value = 0 +} + +private final class RecordingInvalidationSink: RenderInvalidationSink, @unchecked Sendable { + private let lock = Lock(initialState: [ViewIdentity]()) + + var invalidatedSubtrees: [ViewIdentity] { + lock.withLock { $0 } + } + + func invalidate(_ invalidation: RenderInvalidation) { + guard case .subtree(let identity) = invalidation else { return } + lock.withLock { identities in + identities.append(identity) + } + } +} diff --git a/Tests/TUIkitViewTests/StateStorageIdentityTests.swift b/Tests/TUIkitViewTests/StateStorageIdentityTests.swift index a05ea2430..edf38ab3b 100644 --- a/Tests/TUIkitViewTests/StateStorageIdentityTests.swift +++ b/Tests/TUIkitViewTests/StateStorageIdentityTests.swift @@ -22,35 +22,51 @@ struct StateStorageIdentityTests { StateStorage() } - // MARK: - Self-Hydrating State + // MARK: - Dynamic Property Binding - @Test("State self-hydrates from StateStorage when active context is set") - func selfHydrationFromStorage() { + @Test("State binds to storage at the committed identity") + func dynamicPropertyBinding() { let storage = testStorage() let identity = ViewIdentity(path: "TestView") + let context = HydrationContext(identity: identity, storage: storage) - // First construction: creates new entry in storage - StateRegistration.activeContext = HydrationContext(identity: identity, storage: storage) - StateRegistration.counter = 0 - let firstState = State(wrappedValue: 42) - StateRegistration.activeContext = nil + let first = SingleStateOwner(defaultValue: 42) + StateRegistration.bindDynamicProperties(in: first, context: context) + first.value = 99 - // Mutate value through the first state - firstState.wrappedValue = 99 + let reconstructed = SingleStateOwner(defaultValue: 42) + StateRegistration.bindDynamicProperties(in: reconstructed, context: context) - // Second construction at same identity: should get the persisted value (99) - StateRegistration.activeContext = HydrationContext(identity: identity, storage: storage) - StateRegistration.counter = 0 - let secondState = State(wrappedValue: 42) - StateRegistration.activeContext = nil + #expect(reconstructed.value == 99) + } - #expect(secondState.wrappedValue == 99) + @Test("State rebinding isolates identical paths across runtimes") + func runtimeStorageIsolation() { + let firstStorage = testStorage() + let secondStorage = testStorage() + let identity = ViewIdentity(path: "SharedPath") + let firstContext = HydrationContext(identity: identity, storage: firstStorage) + let secondContext = HydrationContext(identity: identity, storage: secondStorage) + let owner = SingleStateOwner(defaultValue: 1) + + StateRegistration.bindDynamicProperties(in: owner, context: firstContext) + owner.value = 10 + + StateRegistration.bindDynamicProperties(in: owner, context: secondContext) + #expect(owner.value == 1) + owner.value = 20 + + let firstReconstruction = SingleStateOwner(defaultValue: 1) + StateRegistration.bindDynamicProperties(in: firstReconstruction, context: firstContext) + let secondReconstruction = SingleStateOwner(defaultValue: 1) + StateRegistration.bindDynamicProperties(in: secondReconstruction, context: secondContext) + + #expect(firstReconstruction.value == 10) + #expect(secondReconstruction.value == 20) } @Test("State uses local box when no active context is set") func localBoxWithoutContext() { - StateRegistration.activeContext = nil - let state = State(wrappedValue: "hello") #expect(state.wrappedValue == "hello") @@ -62,26 +78,51 @@ struct StateStorageIdentityTests { func multipleStateDistinctIndices() { let storage = testStorage() let identity = ViewIdentity(path: "MultiStateView") + let context = HydrationContext(identity: identity, storage: storage) + + let first = MultipleStateOwner() + StateRegistration.bindDynamicProperties(in: first, context: context) + first.number = 20 + first.text = "world" + + let reconstructed = MultipleStateOwner() + StateRegistration.bindDynamicProperties(in: reconstructed, context: context) + + #expect(reconstructed.number == 20) + #expect(reconstructed.text == "world") + } + + @Test("Hydration contexts restore through nested dynamic scopes") + func nestedHydrationScopes() { + let storage = testStorage() + var environment = EnvironmentValues() + environment.stateStorage = storage + let outerContext = RenderContext( + availableWidth: 10, + availableHeight: 1, + environment: environment, + identity: ViewIdentity(path: "Outer") + ) + let innerContext = RenderContext( + availableWidth: 10, + availableHeight: 1, + environment: environment, + identity: ViewIdentity(path: "Inner") + ) - // Simulate a view with two @State properties - StateRegistration.activeContext = HydrationContext(identity: identity, storage: storage) - StateRegistration.counter = 0 - let firstState = State(wrappedValue: 10) - let secondState = State(wrappedValue: "hello") - StateRegistration.activeContext = nil + #expect(StateRegistration.currentContext == nil) - firstState.wrappedValue = 20 - secondState.wrappedValue = "world" + StateRegistration.withHydration(context: outerContext) { + #expect(StateRegistration.currentContext?.identity == outerContext.identity) - // Reconstruct: both should get their persisted values - StateRegistration.activeContext = HydrationContext(identity: identity, storage: storage) - StateRegistration.counter = 0 - let firstReconstructed = State(wrappedValue: 10) - let secondReconstructed = State(wrappedValue: "hello") - StateRegistration.activeContext = nil + StateRegistration.withHydration(context: innerContext) { + #expect(StateRegistration.currentContext?.identity == innerContext.identity) + } - #expect(firstReconstructed.wrappedValue == 20) - #expect(secondReconstructed.wrappedValue == "world") + #expect(StateRegistration.currentContext?.identity == outerContext.identity) + } + + #expect(StateRegistration.currentContext == nil) } // MARK: - View Identity @@ -100,6 +141,33 @@ struct StateStorageIdentityTests { #expect(branch.path == "Root#true") } + @Test("Runtime scopes are stable and nested slots stay distinct") + func scopedIdentityPath() { + let root = ViewIdentity(path: "Root") + + let firstPass = root.scoped("lifecycle.appear") + let secondPass = root.scoped("lifecycle.appear") + let nested = firstPass.scoped("lifecycle.appear") + + #expect(firstPass == secondPass) + #expect(nested != firstPass) + } + + @Test("Explicit keys preserve child identity independently of sibling order") + func keyedChildIdentity() { + let root = ViewIdentity(path: "Root") + + let alphaBeforeReorder = root.keyedChild(type: String.self, key: "alpha") + let betaBeforeReorder = root.keyedChild(type: String.self, key: "beta") + let betaAfterReorder = root.keyedChild(type: String.self, key: "beta") + let alphaAfterReorder = root.keyedChild(type: String.self, key: "alpha") + + #expect(alphaBeforeReorder == alphaAfterReorder) + #expect(betaBeforeReorder == betaAfterReorder) + #expect(alphaBeforeReorder != betaBeforeReorder) + #expect(root.keyedChild(type: String.self, key: "a/b") != root.keyedChild(type: String.self, key: "a#b")) + } + @Test("isAncestor detects path descendants") func ancestorDetection() { let parent = ViewIdentity(path: "A/B") @@ -201,4 +269,53 @@ struct StateStorageIdentityTests { #expect(newStaleBox !== staleBox) #expect(newStaleBox.value == 2) } + + @Test("Keyed state follows reorder and removed keys are collected") + func keyedStateReorderAndRemoval() { + let storage = testStorage() + let root = ViewIdentity(path: "Root") + let firstIdentity = root.keyedChild(type: String.self, key: "first") + let secondIdentity = root.keyedChild(type: String.self, key: "second") + let firstKey = StateStorage.StateKey(identity: firstIdentity, propertyIndex: 0) + let secondKey = StateStorage.StateKey(identity: secondIdentity, propertyIndex: 0) + let firstBox: StateBox = storage.storage(for: firstKey, default: 1) + let secondBox: StateBox = storage.storage(for: secondKey, default: 2) + firstBox.value = 10 + secondBox.value = 20 + + storage.beginRenderPass() + storage.markActive(secondIdentity) + storage.markActive(firstIdentity) + storage.endRenderPass() + + let reorderedFirst: StateBox = storage.storage(for: firstKey, default: 1) + let reorderedSecond: StateBox = storage.storage(for: secondKey, default: 2) + #expect(reorderedFirst === firstBox) + #expect(reorderedSecond === secondBox) + #expect(reorderedFirst.value == 10) + #expect(reorderedSecond.value == 20) + + storage.beginRenderPass() + storage.markActive(firstIdentity) + storage.endRenderPass() + + let recreatedSecond: StateBox = storage.storage(for: secondKey, default: 2) + #expect(recreatedSecond !== secondBox) + #expect(recreatedSecond.value == 2) + } +} + +// MARK: - Fixtures + +private struct SingleStateOwner { + @State var value: Int + + init(defaultValue: Int) { + self._value = State(wrappedValue: defaultValue) + } +} + +private struct MultipleStateOwner { + @State var number = 10 + @State var text = "hello" }