Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Sources/TUIkit/App/RenderLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@ 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()

// 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()
}

Expand Down Expand Up @@ -343,6 +354,7 @@ extension RenderLoop {
environment.preferenceStorage = collectors.preferences
environment.statusBar = collectors.statusBar
environment.appHeader = collectors.appHeader
environment.pendingFrameEffects = collectors.pendingEffects
return environment
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/TUIkit/App/RenderPassCollectors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 56 additions & 12 deletions Sources/TUIkit/Environment/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 31 additions & 79 deletions Sources/TUIkit/Environment/TUIContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
Expand All @@ -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))
}
Expand All @@ -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)
}
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -535,6 +463,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()
Expand Down
8 changes: 7 additions & 1 deletion Sources/TUIkit/Focus/FocusRegistration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading