diff --git a/.claude/openspec/render-phase-effect-commit.md b/.claude/openspec/render-phase-effect-commit.md index c30111cc..57f0f8fd 100644 --- a/.claude/openspec/render-phase-effect-commit.md +++ b/.claude/openspec/render-phase-effect-commit.md @@ -2,7 +2,7 @@ **Change ID**: render-phase-effect-commit **Date**: 2026-07-21 -**Status**: Approved design (brainstormed 2026-07-21) +**Status**: Implemented (2026-07-22, issues #55/#56/#57/#58 — PRs #59/#60/#61 + docs PR) **GitHub Issue**: #13 ([P0-08]) **Depends on**: #10 (stable identity, merged), #12 (keyed traversal, merged) diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 4bf2e60a..31681e50 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -220,6 +220,12 @@ extension RenderLoop { environment.pulsePhase = pulsePhase environment.cursorTimer = cursorTimer + // Traversal window: main-thread invalidations from here until the + // last pass finished are unsupported body side effects and get + // diagnosed (see AppState.beginTraversal). Committed effect actions + // replay after the window and stay legitimate. + tuiContext.appState.beginTraversal() + let scene = evaluateAppBody(environment: environment) if let paletteOverrideScene = scene as? any RootPaletteOverrideProvidingScene, let paletteOverride = paletteOverrideScene.rootPaletteOverride() { @@ -289,6 +295,8 @@ extension RenderLoop { buffer = renderScene(scene, context: correctedContext.withChildIdentity(type: type(of: scene))) } + tuiContext.appState.endTraversal() + // COMMIT (step 6a): the single point where per-frame effect state // reaches the live runtime — the FINAL pass's collectors replace the // live managers' state, and the staged focus registrations are diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index 618b2e16..3bc4d2fb 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -392,6 +392,23 @@ final class TUIContext { invalidationSink: appState ) stateStorage.setInvalidationSink(appState) + // Main-thread invalidations raised while the render loop traverses + // the view tree are unsupported user side effects inside `body`. + // The framework cannot prevent them, but it diagnoses them once per + // frame (RuntimeDiagnostics deduplicates per render pass). + appState.setTraversalViolationHandler { [runtimeDiagnostics] invalidation in + let identity: ViewIdentity + switch invalidation { + case .subtree(let subtreeIdentity): + identity = subtreeIdentity + case .renderOnly, .all: + identity = ViewIdentity(path: "runtime") + } + runtimeDiagnostics.emit(RuntimeDiagnostic( + identity: identity, + message: "State mutated during view body evaluation; move the mutation into an event handler, task, or lifecycle action" + )) + } let existingFocusChangeHandler = focusManager.onFocusChange focusManager.onFocusChange = { [appState] in existingFocusChangeHandler?() diff --git a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md index 2205629b..a54bd2d1 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md @@ -4,7 +4,9 @@ Understand how TUIkit turns your view tree into terminal output: one frame at a ## Overview -Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written. Those writes are collected in a frame buffer and normally flushed with one `write()` syscall. +Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → traverse the view tree (one or more passes) → commit → flush to terminal**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written. Those writes are collected in a frame buffer and normally flushed with one `write()` syscall. + +A single frame may traverse the tree more than once (first-frame header sizing, header correction). Effects therefore never reach live runtime state during traversal: each pass records into scratch collectors, and a single **frame commit** applies only the final pass's results. See . ## What Triggers a Frame @@ -33,10 +35,10 @@ Each call to `RenderLoop.render()` executes these steps in order: Five subsystems are reset at the start of every frame: -- **`KeyEventDispatcher`**: All key handlers are removed. Views re-register them during rendering via `onKeyPress()` modifiers. -- **`PreferenceStorage`**: All preference callbacks are cleared and the stack is reset to a single empty `PreferenceValues`. -- **`FocusManager`**: All focus registrations are cleared. Focusable views re-register during rendering. -- **`StatusBarState`**: Section items are cleared. Views re-register them via `.statusBarItems()` modifiers. +- **`KeyEventDispatcher`**: All key handlers are removed. Views re-declare them during traversal; the frame commit adopts the final pass's handler list. +- **`PreferenceStorage`**: The value stack is reset to a single empty `PreferenceValues`. +- **`FocusManager`**: Focus registrations are collected per pass in a staging area; the last committed sections stay queryable while the tree renders. +- **`StatusBarState`**: Declarative registrations are cleared. Views re-declare them via `.statusBarItems()` modifiers. - **`AppHeaderState`**: Header content is cleared. The `.appHeader()` modifier repopulates it during rendering. Additionally, the `StatusBarState` receives a reference to the current `FocusManager` for section resolution. @@ -99,6 +101,10 @@ The context is passed down the view tree. Each view can create a modified copy f This is where the dual rendering system kicks in. ``WindowGroup`` calls the free function `renderToBuffer()` on its content, which recursively traverses the entire view tree and produces a ``FrameBuffer``. +A frame can perform this traversal up to three times — a first-frame sizing pass, the main pass, and a header-correction pass — and only one of them produces the frame's output. Every traversal writes its effects (handlers, preferences, status-bar items, header buffer, lifecycle records) into fresh per-pass collectors; discarded passes are simply dropped. See . + +> Important: Mutating state from inside a view's `body` (or a `Renderable` implementation) during this traversal is unsupported. The framework cannot prevent it, but it diagnoses it: a main-thread invalidation raised inside the traversal window is reported through `RuntimeDiagnostics` once per frame, and the invalidation is still honored so rendering stays consistent. Move such mutations into event handlers, `.task`, or lifecycle actions. + > See below for details on how views are dispatched. ### Step 7: Build Output Lines @@ -129,13 +135,16 @@ The status bar renders in a separate pass but writes into the **same frame buffe Interrupted calls and partial transfers are retried; permanent failures are propagated after terminal cleanup. -### Step 12: End Lifecycle and State Tracking +### Step 12: Commit Lifetime Effects and Finalize Tracking -Four managers finalize the frame: +After terminal output, the frame commit replays the final pass's lifetime +effect records (`onAppear` actions, `onDisappear` registrations, `.task` +mounts, deferred `onChange`/`onPreferenceChange` actions) in traversal order, +then four managers finalize the frame: -- 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 **`LifecycleManager`** compares the committed 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 on the committed tree's liveness set: any state whose view identity the final pass did not keep alive is removed. +- The **observation registry** removes identities absent from the committed tree. 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. @@ -152,6 +161,57 @@ The status bar renders in a separate pass but within the same buffered frame: The status bar is **never affected** by view dimming or overlays. It always renders at the bottom of the terminal. +## Render Phases and the Frame Commit + +A frame is not a single walk over the view tree. Layout sizing evaluates +subtrees speculatively, the first frame measures the app header before any +output exists, and a header-height correction re-evaluates the whole tree. +TUIkit therefore separates **evaluating** a tree from **committing** its +effects — the same conceptual split SwiftUI uses. + +### Phases + +Every traversal carries a `RenderPhase` on its `RenderContext`: + +| Phase | Meaning | Guarantees | +|-------|---------|------------| +| `.measure` | Layout sizing (per-child `sizeThatFits`, first-frame header sizing) | Bodies may be evaluated arbitrarily often; no effect reaches live runtime state, no observation callbacks are registered | +| `.render` | Candidate-tree evaluation for the frame's output | Effects are recorded per pass, never applied directly — the candidate may still be discarded | + +Committing is **not** a phase a view can observe: no body evaluates while the +frame commits. The commit is an explicit step in the render loop after the +final candidate is known. + +### Two effect patterns, one question + +Every effect site classifies itself with one question: **does the effect +outlive the frame?** + +- **No → pass collector.** Key handlers, preference values, status-bar + declarations, the header buffer, and focus registrations are per-frame + values: the tree re-declares them on every traversal. Each pass writes them + into fresh scratch collectors, and the commit adopts the FINAL pass's + collectors into the live managers wholesale. This mirrors SwiftUI's model + where per-update values are recomputed and the last committed tree simply + replaces the previous one. +- **Yes → pending record.** `onAppear`/`onDisappear` actions, `.task` mounts, + `onChange`/`onPreferenceChange` actions, and GC liveness derive from the + identity diff between committed trees. Traversal only records them; the + commit replays the final pass's records — after terminal output, in + traversal order, exactly once. + +### What a discarded pass guarantees + +The first-frame sizing pass and a superseded main pass are dropped together +with their collectors and records. They start no tasks, fire no actions, +register no handlers or focusables, keep no state alive, and register no +observation callbacks. Only the commit changes terminal output and visible +runtime records. + +Implementation details live in the doc comments of `RenderPhase`, +`RenderPassCollectors`, `PendingFrameEffects`, and the frame choreography +comment on `RenderLoop`. + ## The Dual Rendering System TUIkit has two ways for a view to produce output: @@ -267,10 +327,15 @@ Preferences flow **bottom-up**: the reverse of environment values. Child views s 1. `OnPreferenceChangeModifier` calls `push()`: creates a new collection scope 2. Its child tree renders, and `PreferenceModifier` calls `setValue()` on the current scope -3. `OnPreferenceChangeModifier` calls `pop()`: merges collected values into the parent scope and fires the callback +3. `OnPreferenceChangeModifier` calls `pop()`: merges collected values into the parent scope The `reduce(value:nextValue:)` function on ``PreferenceKey`` controls how multiple values from different children are combined. The default behavior: last value wins. +The `onPreferenceChange` action follows SwiftUI semantics: it fires at the +frame commit when the subtree's reduced value **changed** against the last +committed frame (and once when the subtree first appears) — never during +traversal, and never for unchanged values. + ## ViewModifier Pipeline TUIkit has two modifier architectures: @@ -308,20 +373,24 @@ The `LifecycleManager` tracks view visibility across frames using stable slots d ### onAppear -The `OnAppearModifier` calls `lifecycle.recordAppear(identity:action:)` during rendering: +The `OnAppearModifier` records its appearance during traversal; the record is +applied at the **frame commit**, after the frame reached the terminal: -- The structural slot is marked visible in the current render pass +- The structural slot is marked visible in the committed frame - 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. +> Note: `onAppear` fires at the frame commit — after terminal output, never +> during traversal. A view that only existed in a discarded pass (sizing or +> superseded by a correction) never fires its action, matching SwiftUI's +> model where effects follow the committed tree. ### onDisappear -The `OnDisappearModifier` does two things during rendering: +The `OnDisappearModifier` records two things during traversal, applied at commit: -1. Registers its callback with `lifecycle.registerDisappear(identity:action:)` -2. Marks its structural slot visible with `lifecycle.recordAppear(identity:action:)` +1. Its callback registration for `lifecycle.registerDisappear(identity:action:)` +2. Its structural slot's visibility in the committed frame The actual `onDisappear` callback fires in step 12 (end lifecycle tracking), **after** the entire view tree has rendered. @@ -329,9 +398,10 @@ The actual `onDisappear` callback fires in step 12 (end lifecycle tracking), **a The `TaskModifier` (created by `.task()`) combines appearance tracking with async tasks: -1. The first render at a structural task slot starts one `Task` with the given priority and operation +1. The first committed frame containing 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 +4. A task recorded by a discarded pass is never even started ## Output Optimization diff --git a/Sources/TUIkitView/State/State.swift b/Sources/TUIkitView/State/State.swift index 24ab8483..576be1bb 100644 --- a/Sources/TUIkitView/State/State.swift +++ b/Sources/TUIkitView/State/State.swift @@ -28,6 +28,8 @@ public final class AppState: Sendable { var invalidatesAllCachedOutput = false var invalidatedSubtrees: Set = [] var observers: [@Sendable () -> Void] = [] + var traversalThreadID: ObjectIdentifier? + var traversalViolationHandler: (@Sendable (RenderInvalidation) -> Void)? } /// Lock protecting all mutable state. @@ -136,6 +138,51 @@ extension AppState { state.invalidatesAllCachedOutput = false state.invalidatedSubtrees.removeAll() state.observers.removeAll() + state.traversalThreadID = nil + } + } + + // MARK: - Traversal Diagnostics + + /// Marks the start of a view-tree traversal window. + /// + /// Between ``beginTraversal()`` and ``endTraversal()``, invalidations + /// arriving **from the traversing thread itself** are unsupported user + /// side effects (state mutated while a body evaluates or a view renders) + /// and are reported through the traversal-violation handler. + /// Invalidations from background tasks remain legitimate and stay + /// silent, as do all invalidations outside the window (input handlers, + /// committed effect actions, timers). + /// + /// The window is keyed to the calling thread's identity instead of + /// `Thread.isMainThread`, which is not reliable under the Swift + /// concurrency runtime on Linux. + package func beginTraversal() { + let threadID = ObjectIdentifier(Thread.current) + lock.withLock { state in + state.traversalThreadID = threadID + } + } + + /// Marks the end of a view-tree traversal window. + package func endTraversal() { + lock.withLock { state in + state.traversalThreadID = nil + } + } + + /// Installs the handler that reports main-thread invalidations raised + /// during a traversal window. + /// + /// Set once by the owning runtime (`TUIContext`), which routes the + /// report into its diagnostics collector. + /// + /// - Parameter handler: The violation reporter, or `nil` to disable. + package func setTraversalViolationHandler( + _ handler: (@Sendable (RenderInvalidation) -> Void)? + ) { + lock.withLock { state in + state.traversalViolationHandler = handler } } } @@ -144,7 +191,9 @@ extension AppState { extension AppState: RenderInvalidationSink { public func invalidate(_ invalidation: RenderInvalidation) { - let observers = lock.withLock { state -> [@Sendable () -> Void] in + let currentThreadID = ObjectIdentifier(Thread.current) + + let (observers, violationHandler) = lock.withLock { state -> ([@Sendable () -> Void], (@Sendable (RenderInvalidation) -> Void)?) in state.needsRender = true switch invalidation { @@ -159,10 +208,17 @@ extension AppState: RenderInvalidationSink { state.invalidatedSubtrees.removeAll(keepingCapacity: true) } - return state.observers + let handler = (state.traversalThreadID == currentThreadID) + ? state.traversalViolationHandler + : nil + return (state.observers, handler) } - // Call observers outside the lock to avoid potential deadlocks. + // Report and notify outside the lock to avoid potential deadlocks. + // A main-thread invalidation inside a traversal window is an + // unsupported user side effect; the invalidation itself is still + // honored so rendering stays consistent. + violationHandler?(invalidation) for observer in observers { observer() } diff --git a/Tests/TUIkitTests/BodySideEffectDiagnosticTests.swift b/Tests/TUIkitTests/BodySideEffectDiagnosticTests.swift new file mode 100644 index 00000000..4cb63ebf --- /dev/null +++ b/Tests/TUIkitTests/BodySideEffectDiagnosticTests.swift @@ -0,0 +1,145 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// BodySideEffectDiagnosticTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +/// Specifies the diagnostics for unsupported user side effects inside view +/// bodies (issue #58, acceptance criterion of #13): the framework cannot +/// prevent arbitrary user code from mutating state during traversal, but it +/// must DIAGNOSE it — without crashing, swallowing the invalidation, or +/// looping forever. Legitimate invalidations (committed effect actions, +/// background tasks) stay silent. +@MainActor +@Suite("Body Side-Effect Diagnostics", .serialized) +struct BodySideEffectDiagnosticTests { + + @Test("State mutation during traversal produces one diagnostic per frame") + func stateMutationDuringTraversalIsDiagnosed() { + let harness = FrameHarness(app: MutatingBodyApp()) + + harness.renderFrame() + + let messages = harness.tuiContext.runtimeDiagnostics.messages + #expect(messages.count == 1) + #expect(messages.first?.contains("during view body evaluation") == true) + // The invalidation itself is still honored — rendering continues. + #expect(harness.tuiContext.appState.needsRender) + #expect(!harness.terminal.writtenOutput.isEmpty) + } + + @Test("Imperative status-bar mutation during traversal is diagnosed") + func statusBarMutationDuringTraversalIsDiagnosed() { + let harness = FrameHarness(app: StatusBarMutatingApp()) + + harness.renderFrame() + + let messages = harness.tuiContext.runtimeDiagnostics.messages + #expect(messages.count == 1) + #expect(messages.first?.contains("during view body evaluation") == true) + } + + @Test("State mutation from a committed onAppear action stays silent") + func committedEffectMutationIsNotDiagnosed() { + let harness = FrameHarness(app: AppearMutatingApp()) + // A sink-bound state box, mutated by the appear action at commit — + // the legitimate invalidation path that must NOT be diagnosed. + let key = StateStorage.StateKey( + identity: ViewIdentity(path: "appear-cell"), + propertyIndex: 0 + ) + let box: StateBox = harness.tuiContext.stateStorage.storage(for: key, default: 0) + harness.app.hook.callback = { box.value += 1 } + + harness.renderFrame() + + #expect(harness.tuiContext.runtimeDiagnostics.messages.isEmpty) + // The appear action ran and requested the follow-up frame. + #expect(box.value == 1) + #expect(harness.tuiContext.appState.needsRender) + } +} + +// MARK: - Fixtures + +/// Mutates persistent state in the middle of its render traversal — the +/// unsupported pattern the framework must diagnose. +private struct TraversalMutatingLeaf: View, Renderable { + var body: Never { + fatalError("TraversalMutatingLeaf renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + guard context.phase == .render else { + return FrameBuffer(text: "measuring") + } + let storage = context.environment.stateStorage! + let key = StateStorage.StateKey(identity: context.identity, propertyIndex: 0) + let box: StateBox = storage.storage(for: key, default: 0) + box.value += 1 + return FrameBuffer(text: "mutated \(box.value)") + } +} + +private struct MutatingBodyApp: App { + init() {} + + var body: some Scene { + WindowGroup { + TraversalMutatingLeaf() + } + } +} + +/// Calls the re-render-triggering imperative status-bar API from inside a +/// render traversal. +private struct StatusBarMutatingLeaf: View, Renderable { + var body: Never { + fatalError("StatusBarMutatingLeaf renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + guard context.phase == .render else { + return FrameBuffer(text: "measuring") + } + context.environment.statusBar.setItems([ + StatusBarItem(shortcut: "x", label: "mutation") + ]) + return FrameBuffer(text: "status mutated") + } +} + +private struct StatusBarMutatingApp: App { + init() {} + + var body: some Scene { + WindowGroup { + StatusBarMutatingLeaf() + } + } +} + +/// Mutable callback slot shared between a test and its app fixture. +@MainActor +private final class CallbackCell { + var callback: (() -> Void)? +} + +/// Mutates state from onAppear — a lifetime effect that runs at frame +/// commit, after traversal, and therefore must NOT be diagnosed. +private struct AppearMutatingApp: App { + let hook = CallbackCell() + + init() {} + + var body: some Scene { + WindowGroup { + Text("appear") + .onAppear { hook.callback?() } + } + } +}