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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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