Skip to content

Fix: Preserve subtree liveness across EquatableView cache hits - #64

Merged
phranck merged 7 commits into
mainfrom
issue/14-cache-liveness
Jul 22, 2026
Merged

Fix: Preserve subtree liveness across EquatableView cache hits#64
phranck merged 7 commits into
mainfrom
issue/14-cache-liveness

Conversation

@phranck

@phranck phranck commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the full issue #14 plan: cached subtrees can no longer lose descendant effects, and only provably effect-free content is ever served from the RenderCache.

  • Characterization first (EquatableEffectTests, FrameHarness end-to-end): pinned five losses across cache hits — lifecycle slots/tasks, key handlers, status-bar items, focus registrations, nested cache entries. Notable root-cause finding: the first frame's measurement pass stored into the live cache while effect sites were inert, so the same frame's main pass already hit and the subtree's effects never mounted at all.
  • Effect-delta detection + bypass: on every cache miss, EquatableView snapshots the pass's effect-registration probe (key handlers, preference writes, status-bar declarations, staged focus sections/focusables, deferred lifetime-effect records) around the rendering. Any delta flags the identity in RenderCache — flagged identities never hit, so effect-bearing subtrees render every frame and .equatable() becomes safe to apply anywhere. Measurement passes stay out of the cache entirely.
  • Nested cache liveness: RenderCache.markSubtreeActive keeps entries below a cached root alive across outer hits (applyFrameLiveness + live path).
  • Style invalidation: cache entries carry an EnvironmentFingerprint (foregroundStyle, focusIndicatorColor from the audit); a style change above an .equatable() boundary now misses instead of serving a stale buffer. Palette/appearance stay covered by the global EnvironmentSnapshot, pulsePhase stays excluded.
  • Docs: RenderCycle.md Subtree Memoization rewritten (bypass semantics, liveness guarantees, fingerprint invalidation, refreshed usage guidance).

Public API is unchanged — all new plumbing is package/internal.

Fixes #14

Test plan

  • All five characterization markers flipped green and were dropped — EquatableEffectTests now asserts the contracts directly (plus new section-survival and style-invalidation tests)
  • Old live-path marker test migrated to the end-to-end form
  • ./scripts/test-linux.sh: full warning-fatal gate green on macOS and Linux (1322 tests, zero known issues)

phranck added 7 commits July 22, 2026 02:05
- EquatableEffectTests pins issue #14 end-to-end through the RenderLoop
  pipeline: lifecycle slots/tasks, key handlers, status-bar items, focus
  registrations, and nested cache entries all vanish across
  EquatableView cache hits (withKnownIssue markers)
- Documents the first-frame hit source: the measurement pass stores into
  the live cache while effect sites are inert, so the same frame's main
  pass already hits and the subtree's effects never mount at all
- Migrate the live-path marker test from RuntimeCharacterizationTests to
  the new FrameHarness end-to-end form
- EquatableView classifies its content on every cache miss: the pass's
  effect-registration probe is snapshotted around the rendering, and any
  delta flags the identity in RenderCache so lookups always miss and the
  subtree renders every frame
- The probe (package env key, installed by RenderLoop.passEnvironment)
  sums all per-pass effect sinks: key handlers, preference writes,
  status-bar declarations, staged focus registrations, and deferred
  lifetime-effect records; new counters: PreferenceStorage.writeCount,
  StatusBarState.passRegistrationCount,
  FocusManager.stagedRegistrationCount
- Measurement passes stay out of the cache entirely: a buffer stored in
  .measure let the same frame's output pass hit a subtree whose effects
  were never recorded (they never mounted at all)
- Live path (no collectors) keeps its historical caching semantics
- EquatableEffectTests lifecycle/handler/status-bar/focus markers flip
  green and are dropped; the nested-entry marker stays for Task 3
- RenderCache.markSubtreeActive keeps every entry at or below a cached
  subtree root active without traversal (ancestor logic like
  clearAffected(by:))
- TUIContext.applyFrameLiveness marks subtree roots via
  markSubtreeActive instead of markActive; the EquatableView live path
  gains the same call
- The nested-entry characterization marker flips green and is dropped —
  EquatableEffectTests now asserts all issue #14 effect contracts
  directly
- A focus section declared without focusables inside cacheable content
  produced no probe delta, so the subtree was classified effect-free
  and the section vanished from committed Tab cycling on cache hits
- stagedRegistrationCount now counts staged sections plus focusables;
  new end-to-end test pins the section survival
- EnvironmentFingerprint (type-erased Equatable snapshot, TUIkitView)
  joins identity, view value, and size in the RenderCache key; a
  mismatch misses so a style change above an .equatable() boundary can
  never serve a stale buffer
- RenderLoop installs the per-pass fingerprint probe snapshotting the
  issue #14 audit members: foregroundStyle and focusIndicatorColor
  (pulse-derived, so cached entries inside an active focus section
  refresh every tick); palette/appearance stay covered by the global
  EnvironmentSnapshot clear, pulsePhase itself stays excluded
- Live path stores and matches nil fingerprints, keeping its historical
  cache key
- New end-to-end test: a foregroundStyle change between frames misses
  and re-stores, an unchanged style hits again
- Subtree Memoization rewrite: measure-pass exclusion, effect-bearing
  classification and bypass, liveness guarantees for hits, environment
  fingerprint in the cache key
- Refresh the when-(not)-to-use guidance: effect-bearing subtrees are
  auto-bypassed and safe, wrapping them just adds nothing
@phranck
phranck merged commit b8f98e1 into main Jul 22, 2026
6 checks passed
@phranck
phranck deleted the issue/14-cache-liveness branch July 22, 2026 07:17
wadetregaskis added a commit to wadetregaskis/TUIkit that referenced this pull request Sep 2, 2026
…rified

The closest upstream has come to our own code. Their effect-bearing
bypass is already ours by another route — LifecycleModifier declares a
render side effect at all three registration sites, and EquatableView
refuses to store a buffer whose render tripped the tracker.

The other two are recorded as `queued` rather than guessed:

  Nested cache entries below a cached root may be collectable while
  live. Upstream added RenderCache.markSubtreeActive; ours marks the
  subtree in StateStorage but only the root identity in the cache.
  Symptom would be a miss storm — bad, benign.

  Our cache key has no environment component at all. A .foregroundStyle
  change ABOVE an .equatable() boundary, view value unchanged, has
  nothing to miss on. Symptom would be wrong pixels, so this is the one
  that matters.

Both need a written repro before a fix, and the session that reached them
had no room to do that properly. A wrong `have` here would close a door
on something that serves stale pixels, and PR-45.md already records what
it costs when a probe agrees with its author by accident.

Co-Authored-By: Claude <[email protected]>
wadetregaskis added a commit to wadetregaskis/TUIkit that referenced this pull request Sep 2, 2026
An .equatable() cache hit returns the stored buffer and skips the subtree
entirely, so no view inside ever reaches RenderCache.markActive. A nested
.equatable() below it therefore looked absent from the tree, and
removeInactive collected its entry at the end of every hit frame. The
steady state was one entry where there should have been two, and the
first frame the OUTER value changed, the inner subtree re-rendered from
scratch even though nothing in it had changed.

StateStorage already solves exactly this for the @State inside a cached
subtree — retainSubtree, declared per pass by the same cache-hit path —
so the cache gets the twin rather than upstream's markSubtreeActive
naming. Following the pattern that exists beats importing a second one.

Per-pass, so it does not leak: a subtree that stops being declared is
collectable on the next frame, which the third test pins.

Costs nothing measurable. Stress --bench, per-frame, before → after:

  megalist     400.5µs → 389.5µs
  deep        6842.3µs → 6688.6µs
  textwall    3791.4µs → 3834.7µs
  kitchensink  761.9µs → 762.4µs

All four are inside run-to-run noise and every checksum is identical, so
rendering is unchanged. Nor is that a strong result: the harness barely
uses .equatable(), so it exercises the new retention path only lightly.
What bounds the cost is the shape — the ancestor walk runs only for
entries NOT visited this pass, against the handful of roots that hit.

Also lands the repro for the OTHER PR phranck#64 concern as a characterization:
the cache key has no environment component, so a scoped .foregroundStyle
change above an .equatable() serves the buffer rendered under the old
style. Those two tests assert the wrong behaviour on purpose and will
fail the day it is fixed. The fix is a design fork, not a patch — see
open-questions.md.

Co-Authored-By: Claude <[email protected]>
wadetregaskis added a commit to wadetregaskis/TUIkit that referenced this pull request Sep 2, 2026
…r from it

All three of PR phranck#64's fixes are now resolved against our tree: the
effect-bearing bypass we already had, and both cache contracts
reproduced and fixed. The environment one is `adapt` rather than `have`
because we did not take upstream's shape — a fixed fingerprint list
would leave every style value nobody remembered to add still broken.

open-questions.md is down to the branch note. Queued: 3 → 2, both the
older FrameBuffer and AppState.shared questions.

Records the measurement lesson with the verdict, since that is where
someone revisiting the perf trade would look: the first "+10 to 20%"
readings were cross-time comparisons on a machine that drifts ~20%
between runs. Only interleaved A/B is worth anything here.

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant