Skip to content

[P0-09] Preserve subtree liveness across EquatableView cache hits #14

Description

@phranck

Problem

On a cache hit, EquatableView marks only its wrapper identity active in Sources/TUIkitView/Core/EquatableView.swift:89-102,122-130. Each render pass then resets or prunes state, lifecycle, cache, handlers, focus, preferences, and other registrations in Sources/TUIkit/App/RenderLoop.swift:310-321,401-410.

A visually cached subtree can therefore lose descendant State, tasks, Observation subscriptions, focus/key handlers, preferences, header/status contributions, animations, and nested cache entries. The current tests inspect cache maps in isolation rather than an effect-bearing tree over repeated hits.

Proposed solution

  • Until correctness is proven, bypass caching for subtrees that may carry effects.
  • Represent cached subtree liveness and committed-effect manifests, or replay an equivalent committed tree record on a hit.
  • Include relevant size, proposal, Environment, style, and dependency reads in invalidation.
  • Compare cached and uncached execution through full runtime integration tests.

Acceptance criteria

  • A cache hit keeps every live descendant identity active.
  • State, tasks, lifecycle, Observation, focus, handlers, preferences, header/status, and animations behave identically with caching enabled or disabled.
  • Environment/style/size changes invalidate the affected cache entry.
  • Nested entries are retained and removed exactly with their keyed subtree.
  • No cache optimization is enabled for a subtree whose effect equivalence is unknown.
  • All Swift 6.0 macOS/Linux quality gates are warning-free.

Dependencies

Depends on the committed-effect transaction from P0-08.

Parallelization

Can run in parallel with per-app AppStorage once P0-08 is merged, but cache and committed-effect files must remain owned by this issue.

Commit structure

  • Split this issue into small, thematic, independently revertible commits wherever the work can remain coherent.
  • Every commit must build and keep its applicable tests/gates green. Do not commit an intentionally failing regression test; use local/known-issue characterization or land the test with the smallest fix.
  • Keep characterization/fixtures, mechanical renames or moves, semantic changes, and documentation/migration updates separate when each step remains green.
  • The issue boundary is not a commit boundary; multiple commits are expected for independently reversible changes.

Plan (2026-07-22, builds on the #13 architecture)

Core insight: with per-pass collectors (#56) and the pending-effect log (#57), "does this subtree carry effects?" is measurable as counter deltas during the cache-MISS rendering. Strategy (as proposed above): bypass caching for effect-bearing subtrees, grant full liveness to effect-free ones. Effect-free subtrees cannot own lifecycle slots (those would be pending-log deltas), so no lifecycle subtree-marking is needed at all.

Task 1: End-to-end characterization over repeated hits

Create Tests/TUIkitTests/EquatableEffectTests.swift using FrameHarness (full RenderLoop pipeline). Pin today's losses with withKnownIssue referencing this issue:

  • A cached subtree with .onAppear/.onDisappear fires disappear (and cancels .task) on the second frame's cache hit.
  • A cached subtree's .onKeyPress handler and .statusBarItems declaration vanish after a hit (handlerCount, hasUserItems).
  • A cached subtree containing a focusable loses its focus registration after a hit (currentFocusedID).
  • A NESTED .equatable() entry under a cached root is garbage-collected on the outer hit (renderCache.count).
  • Migrate the existing marker test "Cached descendant lifecycle loss remains characterized for issue [P0-09] Preserve subtree liveness across EquatableView cache hits #14" (RuntimeCharacterizationTests.swift, live-path harness) to the FrameHarness end-to-end form; the live-path harness cannot observe collector deltas by design.
  • Commit: Test: Characterize effect loss across cache hits

Task 2: Effect-delta detection and cache bypass

  • Add test/diagnostic counters where missing: StatusBarState.passRegistrationCount, PreferenceStorage.writeCount (monotonic, incremented in setValue), FocusManager.stagedRegistrationCount. Existing: KeyEventDispatcher.handlerCount, PendingFrameEffects.deferredEffectCount.
  • EquatableView.renderToBuffer (miss path): snapshot the counters before rendering content, compare after. Any delta → store a carriesEffects marker for the identity instead of a reusable buffer entry (RenderCache gains the flag; lookup returns nil for flagged identities so the subtree always renders).
  • Hit path stays as is (markActive + markSubtreeActive via pending sets) — now provably sufficient because cached subtrees are effect-free.
  • Live path (no collectors in the environment): keep current behavior; bypass detection is a RenderLoop-pipeline guarantee.
  • Task 1 lifecycle/handler/status-bar/focus tests flip green → drop their markers.
  • Document the bypass in the EquatableView doc comment (what disables caching and why that is correct-by-construction).
  • Commit: Fix: Bypass subtree caching for effect-bearing content

Task 3: Nested cache liveness

  • RenderCache.markSubtreeActive(_ root:) (ancestor logic like clearAffected(by:)); TUIContext.applyFrameLiveness calls it for activeSubtreeRoots.
  • Task 1 nested-entry test flips green → drop marker.
  • Commit: Fix: Keep nested cache entries alive across outer hits

Task 4: Environment/style invalidation

  • Audit render-affecting Equatable environment reads inside cacheable content (grep environment. reads in Renderables; palette/appearance are already covered by the global EnvironmentSnapshot clear; pulsePhase is deliberately excluded — pulse-animated views are documented non-candidates).
  • Extend the cache entry with an EnvironmentFingerprint (small Equatable struct; expected members from the audit: foregroundStyle, focusIndicatorColor, plus whatever the audit surfaces); mismatch → miss.
  • Test: wrapping a cached view in .foregroundColor(...) whose value changes between frames invalidates the entry.
  • Commit: Fix: Include style environment in cache invalidation

Task 5: Docs, gates, PR

  • Update the "Subtree Memoization" section in RenderCycle.md: bypass semantics, liveness guarantees, fingerprint invalidation; refresh the "when (not) to use" guidance.
  • ./scripts/test-linux.sh green on macOS + Linux; DocC diagnostics-free; API manifest untouched or regenerated via tool (goal: package/internal only).
  • PR against main; this issue closes on merge.

Verified facts (audited 2026-07-22 on main @ da0c247)

  • Hit path marks only wrapper + subtree state/observation: EquatableView.swift (markActive, markSubtreeActivePendingFrameEffects sets); applyFrameLiveness covers stateStorage/observationRegistry/renderCache.markActive(root) — no nested-entry subtree marking (TUIContext.swift).
  • RenderCache has markActive/removeInactive/clearAffected(by:) (ancestor logic exists) but no markSubtreeActive (RenderCache.swift).
  • LifecycleManager slots are "identity:<path>" strings; no subtree queries — not needed under the bypass strategy (TUIContext.swift).
  • ViewIdentity.isAncestor(of:) matches /- and #-separated descendants; scoped slots (/@hex) are path children and therefore covered (ViewIdentity.swift:104-118).
  • Existing counters: KeyEventDispatcher.handlerCount (KeyEvent.swift), PendingFrameEffects.deferredEffectCount (PendingFrameEffects.swift), LifecycleManager.taskCount/disappearCallbackCount (TUIContext.swift).
  • Cache key today: identity + Equatable view snapshot + contextWidth/Height (RenderCache.lookup); global palette/appearance invalidation via EnvironmentSnapshot (RenderLoop.swift).
  • Existing marker test: RuntimeCharacterizationTests.swift "Cached descendant lifecycle loss remains characterized for issue [P0-09] Preserve subtree liveness across EquatableView cache hits #14" — live-path harness, direct lifecycle.recordAppear calls.
  • FrameHarness + fixtures (GrowableHeaderModel, HeightGate) in Tests/TUIkitTests/Support/ ready for end-to-end frames.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: criticalBlocks runtime correctness or the release-quality gate

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions