diff --git a/.claude/openspec/render-phase-effect-commit.md b/.claude/openspec/render-phase-effect-commit.md new file mode 100644 index 000000000..c30111ccb --- /dev/null +++ b/.claude/openspec/render-phase-effect-commit.md @@ -0,0 +1,224 @@ +# OpenSpec: Separate Measurement, Rendering, and Effect Commit + +**Change ID**: render-phase-effect-commit +**Date**: 2026-07-21 +**Status**: Approved design (brainstormed 2026-07-21) +**GitHub Issue**: #13 ([P0-08]) +**Depends on**: #10 (stable identity, merged), #12 (keyed traversal, merged) + +--- + +## Why + +The runtime evaluates view bodies up to three times per frame (first-frame header +measurement, main pass, header-correction pass), but effect registration is not +separated from evaluation. A single ambient `Bool` (`RenderContext.isMeasuring`) +is the only phase mechanism, and it is honored inconsistently: + +- The first-frame header sizing pass (`RenderLoop.render()`, RenderLoop.swift:210-218) + and the correction pass (RenderLoop.swift:237-249) run full renders **without** + marking them as measurement. All effect guards are bypassed there. +- Roughly half of the effect sites carry no guard at all: key handlers + (KeyPressModifier.swift:32, Menu.swift:297, AlertPresentationModifier.swift:108), + preferences (Preferences.swift:28,52-70), `onChange` (OnChangeModifier.swift:43-59), + header/status-bar registration (AppHeaderModifier.swift:52, + StatusBarItemsModifier.swift:60-75), observation tracking and `markActive` + (Renderable.swift:179-196), and the token-based `Spinner`/`_ImageCore` effects. + +Observable defects today: + +1. Effects from discarded trees commit live: `onAppear` fires and `.task` starts + from the measurement pass; a view dropped by the correction pass keeps its + effects and stays "visible" until the next frame because + `currentRenderSlots` accumulates across passes within one frame. +2. Key handlers register 2-3x per frame (`clearHandlers()` runs once per frame, + TUIContext.swift:515). +3. Focusables from the discarded pass win: `FocusSection.register` deduplicates + by `focusID` (FocusSection.swift:40), so the *first* (abandoned) pass provides + the live handlers. +4. `onChange` index claims shift across passes (`onChangeCounters` resets per + frame, StateStorage.swift:184), corrupting first-frame change detection. +5. Preference callbacks fire multiple times per frame; reduce-based values + accumulate (PreferenceKey.swift:150-156). +6. GC operates on the union of all passes instead of the committed tree. + +## Reference model (SwiftUI) + +SwiftUI separates phases strictly: body evaluation and layout measurement are +pure and may run arbitrarily often; effects are bound to identity lifetime in +the **committed** tree; per-frame data (preferences, focus declarations) are +values recomputed each update where the last committed tree simply replaces the +previous one; lifetime effects (`onAppear`, `task`) derive from the identity +diff between committed trees; `onChange`/`onPreferenceChange` actions run after +the update, never during it. + +## Design decision + +**Variant C — hybrid, mirroring SwiftUI's conceptual split** (chosen over a +monolithic frame transaction and over guard-patching): + +The classification rule, documented at every effect site: + +> **Does the effect outlive the frame? No → pass collector. Yes → pending diff.** + +### 1. Phase model + +```swift +public enum RenderPhase: Sendable { + case measure // layout sizing; no effect may reach live runtime state + case render // candidate-tree evaluation; effects recorded, never applied +} +``` + +- `RenderContext.phase: RenderPhase` (default `.render`) **replaces** + `isMeasuring` completely. No deprecation bridge (project is WIP; user + decision 2026-07-21). API-compat manifest is regenerated with the tool. +- Deliberate deviation from the issue text: commit is **not** a context phase. + No view body is ever evaluated during commit, so a `.commit` case would be + dead state. Commit is an explicit step in `RenderLoop` (see choreography). + Document this deviation when closing #13. + +### 2. Pass collectors (effects that do NOT outlive the frame) + +Key handlers, preference values + callbacks, status-bar section items, header +buffer, focus sections/focusables. + +- `RenderLoop` creates **fresh instances of the existing manager types** per + pass and injects them via the environment. Effect sites remain unchanged — + they keep writing to "their manager"; it just is the scratch instance. + (Precedent: `context.isolatedForBackground()` used by Alert.) +- On commit, the final pass's collectors are adopted atomically into the live + managers. Discarded passes are dropped; nothing to roll back because nothing + live was touched. +- Persistent focus state (`activeSectionID`, `focusedID`) stays on the live + `FocusManager`; only per-frame sections/focusables go through the collector. + Today's `endRenderPass` validation becomes part of commit. + +### 3. Pending diff (effects that DO outlive the frame) + +`onAppear` actions, `onDisappear` registrations, `.task` mounts, +`onChange`/`onPreferenceChange` actions, markActive sets for +state/cache/observation GC. + +- During `.render` passes only **records** are collected; no action runs, no + task starts. +- At commit, the final record set is diffed against persistent state: + - New slots → `onAppear` action fires (after tree construction — closer to + the original; `RenderCycle.md` update required). + - Vanished slots → disappear callback + task cancel. + - Task mounts → `updateTask` restart-ID semantics (unchanged). + - `onChange`: (old, new) computed during traversal; action and + `setTrackedValue` deferred to commit. Index claims reset per **pass** + instead of per frame (fixes defect 4). + - GC runs on the **final** tree's active set only. + +### 4. Frame choreography (`RenderLoop.render()`) + +``` +1. beginFrame (tracking reset, once per frame) +2. evaluate App.body (hydration, pure) +3. [first frame only] measure pass phase=.measure, scratch discarded, + only header height kept +4. main pass phase=.render → collectors R1 + records P1 +5. [header height differs] + correction pass phase=.render → R2 + P2; R1/P1 discarded +6. COMMIT (the only point where live state mutates): + a. final collectors → live managers (atomic), focus validation + b. writeFrame (terminal output) + c. lifecycle/task/onChange/preference effects via diff of final records + d. GC with final active set +``` + +Effect actions run after `writeFrame` (6c), consistent with today's +`onDisappear` timing; state changes from `onAppear` trigger the next frame as +before. Header/status-bar renderings inside `writeFrame` are pure output +renderings with isolated, effect-free contexts. + +### 5. Removals (no deprecations) + +- `RenderContext.isMeasuring` (all 15 usage sites migrated). +- Token-based `LifecycleManager` APIs (`recordAppear(token:)`, + `startTask(token:)`, …); `Spinner` and `_ImageCore` migrate to + identity-based slots (sole remaining users). +- `StateRegistration.activeContext` / `counter` / `activeEnvironment` legacy + fallbacks, once no path needs them. + +## Documentation mandate (non-negotiable, user condition) + +1. **In code**: every new type (`RenderPhase`, collectors, pending records) + carries thorough doc comments with purpose, invariants, and the + classification rule. The frame choreography lives as an architecture + comment in `RenderLoop`. Every effect site states which pattern it follows + and why. +2. **In DocC**: `RenderCycle.md` rewritten for the new model (phases, commit + point, changed `onAppear` timing) plus a conceptual section on the internal + structure — behavior and architecture yes, implementation details no. +3. Documentation is a checklist item in every plan, not end-polish. + +## Testing strategy + +1. **Characterization before rebuild** (own commits, gates green): tests + pinning today's misbehavior (first-frame `onAppear` from measure pass, + duplicate handlers from correction pass, `onChange` index shift), marked + `withKnownIssue` where they show the wrong behavior (pattern exists for + issue #14). They flip green during the rebuild and the markers drop. +2. **Harness**: `RuntimeCharacterizationHarness` gains the commit step and + phase traces (`TraceRecorder` exists). Acceptance criteria become directly + testable: + - measure pass: zero effects (no tasks, handlers, state/cache/terminal mutation) + - abandoned frame: zero effects + - single-pass vs. correction-pass: identical buffer + identical effect traces + - deterministic traces for header, status bar, preferences, focus, task, + observation +3. **Gates per commit**: `./scripts/test-linux.sh` (macOS + Linux, + warning-fatal), DocC diagnostics-free, API manifest regenerated via tool. + Every commit green on its own (independently revertible steps). + +## Plan split (each with its own Plan-Nr. via `plans next`) + +1. **Plan 1 — Characterization + phase foundation**: characterization tests; + `RenderPhase` replaces `isMeasuring` completely; measure pass marked + `.measure` in `RenderLoop`. No collectors yet. +2. **Plan 2 — Pass collectors**: scratch instances for key handlers, + preferences, status bar, header, focus; commit step 6a; correction pass + discards cleanly. +3. **Plan 3 — Pending diff**: lifecycle/task/onChange/onPreferenceChange to + record+commit; GC on final active set; `onAppear` timing change; + `Spinner`/`_ImageCore` migration; remove token APIs + + `StateRegistration` legacy. +4. **Plan 4 — Docs + closure**: `RenderCycle.md` + architecture article; + diagnostics for unsupported user side effects in `body` (acceptance + criterion, via existing `RuntimeDiagnostics`); final gate runs; close #13 + documenting the `.commit` deviation. + +## Acceptance criteria (from issue #13) + +- Framework-controlled measurement starts no tasks, registers no live + handlers/subscriptions, commits no state, storage, cache, lifecycle, or + terminal mutation. Arbitrary user side effects inside `body` are unsupported + and are documented/diagnosed. +- An abandoned frame has no lifecycle or terminal side effects. +- Only commit changes terminal output and visible runtime records. +- Buffer and committed effects are identical across single-pass and + correction-pass layouts. +- Deterministic phase traces cover header, status bar, preferences, focus, + task, and Observation paths. +- Swift 6.0 macOS/Linux gates and DocC complete without diagnostics. + +## Verified facts (audited 2026-07-21 on main @ 3219f2e) + +- `RenderContext.isMeasuring` — RenderContext.swift:58; set only in + ChildInfo.swift:232,252. +- Unmarked measure/correction passes — RenderLoop.swift:210-218, 237-249. +- Guarded sites — LifecycleModifier.swift:25,54,87; FocusRegistration.swift:67,107,121; + FocusSectionModifier.swift:47,59; ModalPresentationModifier.swift:68; + AlertPresentationModifier.swift:101; NavigationSplitView.swift:248,257. +- Unguarded sites — see "Why" above (all file:line refs re-checked post-pull). +- Per-frame reset points — TUIContext.beginRenderPass (TUIContext.swift:514-527), + endRenderPass (TUIContext.swift:530-536). +- Existing isolation precedent — `RenderContext.isolatedForBackground()` + (RenderContext+TUIContext.swift:49; used by AlertPresentationModifier.swift:94, + ModalPresentationModifier.swift:60). +- Harness — Tests/TUIkitTests/Support/RuntimeCharacterizationHarness.swift; + known-issue pattern — RuntimeCharacterizationTests.swift:227-253. +- Gate script — `./scripts/test-linux.sh` (repo root). diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 7d8493541..28a7c045e 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -79,6 +79,23 @@ internal struct RenderBackgroundCodes: Equatable { /// 12. End lifecycle tracking (fires onDisappear for removed views) /// ``` /// +/// ## Render phases +/// +/// A frame can traverse the view tree up to three times. Each traversal +/// carries a ``RenderPhase`` on its `RenderContext`: +/// +/// - **First-frame header sizing** runs in `.measure`: it only discovers the +/// app header height, so phase-guarded effect sites (lifecycle, task, +/// focus registration) stay inert. `AppHeaderModifier` intentionally +/// remains unguarded — the header buffer IS the measured output. +/// - **Main pass** and, when the header height changed, the **correction +/// pass** run in `.render` and produce the frame's candidate buffers. +/// +/// Not yet guaranteed (tracked by issues #56/#57): the correction pass still +/// re-registers per-frame effects (e.g. key handlers) on top of the main +/// pass, and lifetime effects still apply during traversal instead of at a +/// single commit point. See ``RenderPhase`` for the target model. +/// /// ## Diff-Based Rendering /// /// `RenderLoop` uses a `FrameDiffWriter` to compare each frame's output @@ -208,11 +225,18 @@ extension RenderLoop { // This prevents visible content jumping. let appHeaderHeight: Int if isFirstFrame { - let measureContext = RenderContext( + var measureContext = RenderContext( availableWidth: terminalWidth, availableHeight: terminalHeight - statusBarHeight, environment: environment ) + // This traversal only exists to size the app header — run it in + // the measure phase so guarded effect sites (lifecycle, task, + // focus) stay inert. Deliberate exception: AppHeaderModifier is + // NOT phase-guarded, because writing the header buffer is the + // very output this pass measures. Isolating that write into a + // discardable per-pass collector is issue #56. + measureContext.phase = .measure _ = renderScene(scene, context: measureContext.withChildIdentity(type: type(of: scene))) appHeaderHeight = appHeader.height isFirstFrame = false diff --git a/Sources/TUIkit/Focus/FocusRegistration.swift b/Sources/TUIkit/Focus/FocusRegistration.swift index f688db727..84777d36e 100644 --- a/Sources/TUIkit/Focus/FocusRegistration.swift +++ b/Sources/TUIkit/Focus/FocusRegistration.swift @@ -64,7 +64,7 @@ struct FocusRegistration { register(context: context, handler: handler) - let isFocused = context.isMeasuring ? false : context.environment.focusManager.isFocused(id: persistedFocusID) + let isFocused = context.phase == .render && context.environment.focusManager.isFocused(id: persistedFocusID) return Self(persistedFocusID: persistedFocusID, isFocused: isFocused) } @@ -104,7 +104,7 @@ struct FocusRegistration { /// - context: The current render context. /// - handler: The focusable handler to register. static func register(context: RenderContext, handler: Focusable) { - guard !context.isMeasuring else { return } + guard context.phase == .render else { return } context.environment.focusManager.register(handler, inSection: context.environment.activeFocusSectionID) context.environment.stateStorage!.markActive(context.identity) } @@ -118,6 +118,6 @@ struct FocusRegistration { /// - focusID: The focusID to check. /// - Returns: `true` if the view is focused. static func isFocused(context: RenderContext, focusID: String) -> Bool { - context.isMeasuring ? false : context.environment.focusManager.isFocused(id: focusID) + context.phase == .render && context.environment.focusManager.isFocused(id: focusID) } } diff --git a/Sources/TUIkit/Focus/KeyEvent.swift b/Sources/TUIkit/Focus/KeyEvent.swift index 0700071dd..0b44ee1a4 100644 --- a/Sources/TUIkit/Focus/KeyEvent.swift +++ b/Sources/TUIkit/Focus/KeyEvent.swift @@ -35,6 +35,11 @@ extension KeyEventDispatcher { handlers.removeAll() } + /// Number of registered handlers for tests and diagnostics. + var handlerCount: Int { + handlers.count + } + /// Dispatches a key event to handlers. /// /// - Parameter event: The key event to dispatch. diff --git a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift index 8523325d3..b02f4f7c2 100644 --- a/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/AlertPresentationModifier.swift @@ -98,7 +98,7 @@ extension AlertPresentationModifier: Renderable { // The alert section becomes the active section, so Tab/arrows // only navigate within the alert's focusable elements (buttons). let sectionID = Self.alertSectionID - if !context.isMeasuring { + if context.phase == .render { focusManager.registerSection(id: sectionID) focusManager.activateSection(id: sectionID) } diff --git a/Sources/TUIkit/Modifiers/FocusSectionModifier.swift b/Sources/TUIkit/Modifiers/FocusSectionModifier.swift index 9ee170c46..745343639 100644 --- a/Sources/TUIkit/Modifiers/FocusSectionModifier.swift +++ b/Sources/TUIkit/Modifiers/FocusSectionModifier.swift @@ -44,7 +44,7 @@ extension FocusSectionModifier: Renderable { let focusManager = context.environment.focusManager // Register the section with the focus manager (idempotent, skip during measurement). - if !context.isMeasuring { + if context.phase == .render { focusManager.registerSection(id: sectionID) } @@ -56,7 +56,7 @@ extension FocusSectionModifier: Renderable { // If this section is active, compute the breathing indicator color. // The first border view in the subtree will consume this and render ●. // Never active during measurement. - if !context.isMeasuring && focusManager.isActiveSection(sectionID) { + if context.phase == .render && focusManager.isActiveSection(sectionID) { let accentColor = context.environment.palette.accent let dimColor = accentColor.opacity(ViewConstants.focusBorderDim) sectionContext.environment.focusIndicatorColor = Color.lerp(dimColor, accentColor, phase: context.environment.pulsePhase) diff --git a/Sources/TUIkit/Modifiers/LifecycleModifier.swift b/Sources/TUIkit/Modifiers/LifecycleModifier.swift index cae769529..231bccaad 100644 --- a/Sources/TUIkit/Modifiers/LifecycleModifier.swift +++ b/Sources/TUIkit/Modifiers/LifecycleModifier.swift @@ -22,7 +22,7 @@ struct OnAppearModifier: View { extension OnAppearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.appear") - if !context.isMeasuring { + if context.phase == .render { _ = context.environment.lifecycle!.recordAppear( identity: scopedContext.identity, action: action @@ -51,7 +51,7 @@ struct OnDisappearModifier: View { extension OnDisappearModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.disappear") - if !context.isMeasuring { + if context.phase == .render { let lifecycle = context.environment.lifecycle! lifecycle.registerDisappear(identity: scopedContext.identity, action: action) _ = lifecycle.recordAppear(identity: scopedContext.identity, action: {}) @@ -84,7 +84,7 @@ struct TaskModifier: View { extension TaskModifier: Renderable { func renderToBuffer(context: RenderContext) -> FrameBuffer { let scopedContext = context.withIdentityScope("lifecycle.task") - if !context.isMeasuring { + if context.phase == .render { context.environment.lifecycle!.updateTask( identity: scopedContext.identity, id: MountedTaskID.value, diff --git a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift index 89bd41e26..8e9a83af5 100644 --- a/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift +++ b/Sources/TUIkit/Modifiers/ModalPresentationModifier.swift @@ -65,7 +65,7 @@ extension ModalPresentationModifier: Renderable { // The modal section becomes the active section, so Tab/arrows // only navigate within the modal's focusable elements. let sectionID = Self.modalSectionID - if !context.isMeasuring { + if context.phase == .render { focusManager.registerSection(id: sectionID) focusManager.activateSection(id: sectionID) } diff --git a/Sources/TUIkit/Views/NavigationSplitView.swift b/Sources/TUIkit/Views/NavigationSplitView.swift index b9d8ce0dd..6ea28ae0c 100644 --- a/Sources/TUIkit/Views/NavigationSplitView.swift +++ b/Sources/TUIkit/Views/NavigationSplitView.swift @@ -245,7 +245,7 @@ private struct _NavigationSplitViewCore(_ view: V, proposal: ProposedSize, context: Re return ViewSize(width: min, height: min, isWidthFlexible: true, isHeightFlexible: true) } - // Use Layoutable if available (mark as measuring to suppress side-effects) + // Use Layoutable if available (switch to the measure phase to suppress side-effects) if let layoutable = view as? Layoutable { var measureContext = context - measureContext.isMeasuring = true + measureContext.phase = .measure return layoutable.sizeThatFits(proposal: proposal, context: measureContext) } @@ -249,7 +249,7 @@ public func measureChild(_ view: V, proposal: ProposedSize, context: Re // Fallback: render to measure (without side-effects) var measureContext = context - measureContext.isMeasuring = true + measureContext.phase = .measure // Clear hasExplicitWidth so views report their natural (minimum) size // instead of expanding to fill the full available width. let wasExplicitWidth = measureContext.hasExplicitWidth diff --git a/Sources/TUIkitView/Rendering/RenderContext.swift b/Sources/TUIkitView/Rendering/RenderContext.swift index 202f4e2a9..158d92e2d 100644 --- a/Sources/TUIkitView/Rendering/RenderContext.swift +++ b/Sources/TUIkitView/Rendering/RenderContext.swift @@ -51,11 +51,13 @@ public struct RenderContext { /// the available height or shrink to fit their content. public var hasExplicitHeight: Bool = false - /// Whether this is a measurement pass (no side-effects should occur). + /// The evaluation phase of the current render pass. /// - /// Set to true during two-pass layout when measuring non-Layoutable views. - /// Views should skip side-effects like focus registration when this is true. - public var isMeasuring: Bool = false + /// Defaults to ``RenderPhase/render``. Layout code switches to + /// ``RenderPhase/measure`` before sizing traversals so that effect sites + /// can tell sizing work apart from output work. See ``RenderPhase`` for + /// the invariants of each phase and the effect classification rule. + public var phase: RenderPhase = .render /// Creates a new RenderContext. /// diff --git a/Sources/TUIkitView/Rendering/RenderPhase.swift b/Sources/TUIkitView/Rendering/RenderPhase.swift new file mode 100644 index 000000000..4cca1f81f --- /dev/null +++ b/Sources/TUIkitView/Rendering/RenderPhase.swift @@ -0,0 +1,68 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// RenderPhase.swift +// +// Created by LAYERED.work +// License: MIT + +/// The evaluation phase of the current render pass. +/// +/// A single frame may traverse the view tree several times before anything +/// reaches the terminal: +/// +/// 1. **Layout measurement** — containers call `sizeThatFits` (or render to +/// measure) arbitrarily often to negotiate sizes, and `RenderLoop` +/// performs a full sizing pass on the first frame to discover the app +/// header height. None of these traversals produce the frame's output. +/// 2. **Candidate-tree evaluation** — the pass whose buffer becomes (or may +/// become) the frame's terminal output. A frame can evaluate more than +/// one candidate: when the header height turns out different from the +/// estimate, a correction pass re-evaluates the tree and the earlier +/// candidate is discarded. +/// +/// `RenderPhase` makes this distinction explicit on ``RenderContext/phase`` +/// so that effect sites can tell sizing work apart from output work. +/// +/// ## Invariants +/// +/// - In ``measure``, **no effect may reach live runtime state**: no lifecycle +/// or task mounting, no focus or handler registration, no state, cache, +/// preference, header, status-bar, or terminal mutation. Bodies must be +/// evaluable arbitrarily often without observable consequences. +/// - In ``render``, effects belong to a *candidate* tree. They must not be +/// applied to live runtime state during traversal either, because the +/// candidate may still be discarded by a correction pass. Instead they are +/// recorded and applied exactly once when the frame commits (see the +/// classification rule below). +/// - Committing is **not** a phase of this enum: no view body is ever +/// evaluated while the frame commits. The commit is an explicit step in +/// `RenderLoop.render()` after the final candidate is known. +/// +/// ## Classifying an effect +/// +/// When writing or reviewing an effect site, ask one question: +/// **"Does the effect outlive the frame?"** +/// +/// - **No** (key handlers, preference values, status-bar items, header +/// buffer, focus registrations): write it into the current pass's scratch +/// collector. The final pass's collector replaces the live state wholesale; +/// discarded passes are simply dropped. +/// - **Yes** (`onAppear`/`onDisappear` actions, `.task` mounts, `onChange` +/// and `onPreferenceChange` actions, GC liveness): record it. The frame +/// commit diffs the final records against persistent runtime state and +/// applies the difference exactly once. +/// +/// This mirrors SwiftUI's model, where per-update values are recomputed and +/// replaced with each committed tree, while lifetime effects derive from the +/// identity diff between committed trees. +/// +/// - SeeAlso: ``RenderContext/phase``, and the frame choreography comment on +/// `RenderLoop` for where each phase begins and ends. +public enum RenderPhase: Sendable { + /// Layout sizing. Bodies may be evaluated arbitrarily often; no effect + /// may reach live runtime state. + case measure + + /// Candidate-tree evaluation for the frame's output. Effects are + /// recorded for the frame commit, never applied directly. + case render +} diff --git a/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift b/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift new file mode 100644 index 000000000..6debc6639 --- /dev/null +++ b/Tests/TUIkitTests/RenderPhaseCharacterizationTests.swift @@ -0,0 +1,206 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// RenderPhaseCharacterizationTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +/// Characterizes effect leaks across the render passes of a single frame. +/// +/// A frame can traverse the view tree up to three times (first-frame header +/// measurement, main pass, header-correction pass). These tests pin the +/// current misbehavior where effects escape from passes whose trees are +/// discarded. Tests that assert the DESIRED behavior are wrapped in +/// `withKnownIssue` until the corresponding fix lands (issue #13, sub-issues +/// #56/#57); once fixed, the marker must be removed. The measure-pass tests +/// are already hard expectations since the first-frame sizing traversal runs +/// in `RenderPhase.measure` (#55). +@MainActor +@Suite("Render Phase Characterization", .serialized) +struct RenderPhaseCharacterizationTests { + + @Test("Measure pass does not fire onAppear for views absent from the final tree") + func measurePassDoesNotFireOnAppear() { + let harness = FrameHarness(app: MeasureGateApp()) + + harness.renderFrame() + + #expect(!harness.app.trace.snapshot().contains("appear:measure-only")) + } + + @Test("Measure pass does not mount tasks for views absent from the final tree") + func measurePassDoesNotMountTasks() { + let harness = FrameHarness(app: MeasureGateApp()) + + harness.renderFrame() + + #expect(harness.tuiContext.lifecycle.taskCount == 0) + } + + @Test("A frame with a correction pass registers key handlers exactly once") + func correctionPassRegistersHandlersOnce() { + let harness = FrameHarness(app: CorrectionHeaderApp()) + + // Frame 1 establishes the header height estimate. + harness.renderFrame() + // Growing the header invalidates the estimate, forcing frame 2 + // through the header-correction pass (main pass + corrected pass). + harness.app.model.lineCount = 3 + harness.renderFrame() + + withKnownIssue("Issue #56: main and correction pass both register handlers") { + #expect(harness.tuiContext.keyEventDispatcher.handlerCount == 1) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } + + @Test("onChange(initial:) fires exactly once in the first frame") + func onChangeInitialFiresOncePerFrame() { + let harness = FrameHarness(app: OnChangeApp()) + + harness.renderFrame() + + withKnownIssue("Issue #57: each pass claims a fresh onChange index and re-fires") { + #expect(harness.app.counter.value == 1) + } matching: { issue in + guard case .expectationFailed = issue.kind else { return false } + return true + } + } +} + +// MARK: - Frame Harness + +/// Drives a `RenderLoop` against a `MockTerminal` for one app instance. +/// +/// Unlike `RuntimeCharacterizationHarness` (which renders single views via +/// `renderToBuffer`), this harness exercises the full frame pipeline — +/// including the first-frame header measurement and the header-correction +/// pass — which is exactly where phase separation matters. +@MainActor +private final class FrameHarness { + let app: A + let tuiContext: TUIContext + let terminal: MockTerminal + + private let renderLoop: RenderLoop + + init(app: A, width: Int = 40, height: Int = 24) { + let tuiContext = TUIContext() + let terminal = MockTerminal() + terminal.size = (width, height) + tuiContext.statusBar.showSystemItems = false + + self.app = app + self.tuiContext = tuiContext + self.terminal = terminal + self.renderLoop = RenderLoop( + app: app, + terminal: terminal, + statusBar: tuiContext.statusBar, + appHeader: tuiContext.appHeader, + focusManager: tuiContext.focusManager, + paletteManager: tuiContext.paletteManager, + appearanceManager: tuiContext.appearanceManager, + tuiContext: tuiContext + ) + } + + func renderFrame() { + renderLoop.render() + } +} + +// MARK: - Measure-Only Gating + +/// Content height in the main pass: 24 (terminal) − 0 (status bar hidden) +/// − 2 (header: one line + divider). The measure pass runs with the full +/// 24 lines because the header height is not yet known, so a child gated +/// at ≥ 23 lines exists ONLY in the measure pass. +private let measureOnlyHeightThreshold = 23 + +/// Renders an effectful child only when the available height is at least +/// ``measureOnlyHeightThreshold`` — i.e. only during the first-frame +/// measurement pass, never in the final tree. +private struct MeasureOnlyGateView: View, Renderable { + let trace: TraceRecorder + + var body: Never { + fatalError("MeasureOnlyGateView renders via Renderable") + } + + func renderToBuffer(context: RenderContext) -> FrameBuffer { + guard context.availableHeight >= measureOnlyHeightThreshold else { + return FrameBuffer(text: "short") + } + let measureOnlyChild = Text("tall") + .onAppear { trace.record("appear:measure-only") } + .task {} + return TUIkit.renderToBuffer(measureOnlyChild, context: context) + } +} + +private struct MeasureGateApp: App { + let trace = TraceRecorder() + + init() {} + + var body: some Scene { + WindowGroup { + MeasureOnlyGateView(trace: trace) + .appHeader { Text("Header") } + } + } +} + +// MARK: - Correction-Pass Fixtures + +/// Mutable header size shared between the test and the app fixture. +private final class HeaderModel { + var lineCount = 1 +} + +private struct CorrectionHeaderApp: App { + let model = HeaderModel() + + init() {} + + var body: some Scene { + WindowGroup { + Text("Body") + .onKeyPress(.enter) {} + .appHeader { + VStack { + ForEach(Array(0..