Skip to content

Add ReactivePage.InvalidateLayout() for runtime layout rebuilds#220

Merged
Aaronontheweb merged 4 commits into
devfrom
feature/reactive-page-invalidate-layout
May 23, 2026
Merged

Add ReactivePage.InvalidateLayout() for runtime layout rebuilds#220
Aaronontheweb merged 4 commits into
devfrom
feature/reactive-page-invalidate-layout

Conversation

@Aaronontheweb
Copy link
Copy Markdown
Owner

@Aaronontheweb Aaronontheweb commented May 23, 2026

Problem

ReactivePage<TViewModel>.OnNavigatedTo builds and caches _layoutRoot exactly once and never re-runs BuildLayout(). Any value captured inside BuildLayout() and baked into a layout-node field (like HeightAuto's SizeConstraint.Auto record) is permanently frozen at first-navigation time. Consumers have no way to trigger a rebuild — terminal resize handlers can't force the layout to re-evaluate height/width-dependent constraints.

Fix

Add protected void InvalidateLayout() to ReactivePage<TViewModel>. Discards the cached _layoutRoot and rebuilds via BuildLayout(), preserving everything the user cares about (Subscriptions, KeyBindings, and user focus when the target node survives the rebuild).

Refactor: move the framework's IInvalidatingNode.Invalidated → RequestRedraw subscription to a dedicated _layoutSubscriptions CompositeDisposable so InvalidateLayout can tear it down without touching user code.

Consumer usage

// In OnBound, post #219:
ViewModel.Input.OfType<IInputEvent, ResizeEvent>()
    .Subscribe(_ => InvalidateLayout())
    .DisposeWith(Subscriptions);

Hardened lifecycle contract (post review)

Initial commit was functional but fragile. Code review surfaced lifecycle / exception-safety / UX gaps that this PR now addresses:

Lifecycle state (_isActivated / _isDisposed / _isRebuilding flags):

  • Throws ObjectDisposedException if InvalidateLayout or OnNavigatedTo is called after Dispose. Verified R3 CompositeDisposable.Clear/Add silently no-op on a disposed bag, so without the guard a late InvalidateLayout would build a fresh tree, OnActivate every node (starting timers/animations), then leak the whole tree.
  • Throws InvalidOperationException on re-entrant InvalidateLayout (from inside BuildLayout or a node lifecycle callback) — prevents unbounded recursion and divergent state.
  • No-ops when inactive (before first OnNavigatedTo, or between OnNavigatingFrom and the next OnNavigatedTo) — prevents resurrecting an inactive page and the resulting double-subscribe + double-activate on the next navigation.
  • Idempotent OnNavigatedTo — skips wiring path when already active so a user override calling base.OnNavigatedTo() twice doesn't pile up duplicate _layoutSubscriptions.
  • Idempotent Dispose.

Exception-safe build-then-swap:

  • Builds the new tree FIRST, then OnActivate's it, then atomically swaps _layoutRoot, then deactivates the old. If BuildLayout throws, the page keeps its existing layout — no half-state with cleared subscriptions and a null root.
  • BuildLayout returning null throws InvalidOperationException with a clear message instead of silently leaving _layoutRoot null and rendering blank.

Focus preservation:

  • No longer unconditionally re-applies FocusPolicy. If the user has Tab'd to a focusable AND that node still exists in the new tree (common case when BuildLayout reuses page-field nodes), focus is preserved. Only when the focused node is orphaned does the policy default re-apply.

Redraw trigger:

  • InvalidateLayout explicitly calls ViewModel.RequestRedraw() at the end so a resize handler that only invalidates produces a visible change.

What's documented (not enforced)

  • Orphan-container leak: ContainerNode.Dispose() recurses into children, so we can't Dispose the old tree without destroying user-held leaves (e.g., a StreamingTextNode page field). The old container's _childInvalidationSubscriptions to those leaves stay live until page Dispose. Repeated invalidate accumulates dead containers. Documented in the XML remarks; consumers using heavy reusable nodes should prefer reactive bindings inside BuildLayout over frequent invalidate.
  • User Subscriptions targeting BuildLayout-created nodes: those subscriptions keep firing into orphaned nodes after invalidate. Documented; consumers should bind to page-field nodes (initialized in OnBound and reused) or re-subscribe at the start of each BuildLayout.
  • Non-LayoutNode ILayoutNode wrappers: not deactivated by the pattern match (pre-existing behavior in OnNavigatingFrom/Dispose). Documented in remarks.
  • Thread affinity: InvalidateLayout must be called on the same dispatcher thread that drives navigation/rendering.

Tests

tests/Termina.Tests/Reactive/ReactivePageInvalidateLayoutTests.cs — 17 tests total (5 original + 12 new):

Test Covers
TriggersBuildLayoutAgain, ReplacesLayoutRoot Basic rebuild
PreservesUserSubscriptions, PreservesKeyBindings User state preserved
SeesUpdatedExternalStateInBuildLayout Consumer-shaped use case
BeforeOnNavigatedTo_IsNoOp Inactive-before-first-nav guard
AfterOnNavigatingFrom_IsNoOp Inactive-between-navs guard
AfterDispose_Throws, OnNavigatedTo_AfterDispose_Throws Disposed guard
Dispose_IsIdempotent Idempotent dispose
ReEntrant_Throws Re-entrancy guard (page re-enters from BuildLayout)
NullBuildLayout_Throws Null contract enforcement
BuildLayoutThrows_LeavesLayoutIntact Build-then-swap rollback
RequestsRedraw Explicit redraw trigger
OldInvalidatingNode_DoesNotDriveRedraws Old subscription disposed
NewInvalidatingNode_DrivesRedraws New subscription wired
RepeatedCalls_DoNotAccumulateSubscriptions No subscription pile-up

The last three close the gap the original tests had — all originals used TextNode which is NOT IInvalidatingNode, so the entire subscription-leak surface was untested.

Test plan

  • dotnet test tests/Termina.Tests — 1116/1116 pass (17 InvalidateLayout + 1099 existing).
  • No behavior change for existing pages that don't call InvalidateLayout or override the lifecycle methods.
  • No new public surface beyond a single protected method (InvalidateLayout).

`OnNavigatedTo` builds and caches `_layoutRoot` exactly once
(`if (_layoutRoot == null) _layoutRoot = BuildLayout();`). Values
captured inside `BuildLayout()` — e.g., terminal dimensions baked into
a `HeightAuto(min, max)` `SizeConstraint.Auto` record, theme colors
frozen on nodes — are permanently fixed at first-navigation time.
There is no way for consumer code to react to runtime state changes
that affect layout structure (terminal resize being the obvious case).

Add `protected void InvalidateLayout()` to `ReactivePage<TViewModel>`
that discards the cached layout root so the next render rebuilds via
`BuildLayout()`. User subscriptions in `Subscriptions` and registered
`KeyBindings` are preserved; the framework-internal invalidation→redraw
subscription is moved to a dedicated `_layoutSubscriptions` composite
so `InvalidateLayout` can replace it without accumulating dead
subscriptions or leaking through user code.

The old layout is deactivated but NOT disposed — page authors commonly
hold field references to nodes constructed in `OnBound` and reused as
content inside `BuildLayout` (e.g., a `StreamingTextNode` that wraps a
scroll buffer); disposing would destroy that state. The orphan wrapper
nodes built only inside `BuildLayout` are garbage-collected.

Adds `ReactivePageInvalidateLayoutTests` covering: BuildLayout called
again, layout root replaced (different instance), user subscriptions
preserved, key bindings preserved, and a consumer-shaped scenario
where external state captured in BuildLayout updates after invalidate.

Discovered while implementing terminal-size-aware approval prompts in
netclaw — the static panel cap captured at first navigation never
updates on resize, even though the inner DynamicLayoutNode does.
InvalidateLayout gives consumers a clean way to fix that downstream
without requiring framework auto-invalidation that could surprise
existing Termina users.
Initial commit shipped a working but fragile InvalidateLayout API.
Code review surfaced multiple lifecycle / exception-safety / UX gaps.
This commit addresses all of them and locks behavior in with tests.

Lifecycle state (new _isActivated/_isDisposed/_isRebuilding flags):

- Throw ObjectDisposedException when InvalidateLayout or OnNavigatedTo
  is called after Dispose. Previously R3 CompositeDisposable.Clear /
  Add silently no-op'd on a disposed bag, so a late InvalidateLayout
  would build a fresh tree, OnActivate every node (starting timers/
  animations), then leak the whole tree since Dispose had already run.
- Throw InvalidOperationException when InvalidateLayout re-enters via
  BuildLayout or a node lifecycle callback. Previously unbounded
  recursion or a divergent state (inner tree leaked, outer tree
  overwrites _layoutRoot) was possible.
- No-op InvalidateLayout when the page is not currently active (before
  first OnNavigatedTo, or between OnNavigatingFrom and the next
  OnNavigatedTo). Previously the page would silently re-activate
  while the framework still thought it was inactive, then double-
  subscribe + double-activate on the next navigation.
- Idempotent OnNavigatedTo: skip the wiring path when already active
  so a user override calling base.OnNavigatedTo() twice doesn't pile
  up duplicate _layoutSubscriptions entries.
- Idempotent Dispose: short-circuit on _isDisposed.

Exception-safe build-then-swap:

- InvalidateLayout now builds the new tree FIRST, OnActivates it,
  then atomically swaps _layoutRoot, then deactivates the old tree.
  If BuildLayout throws, the page keeps its existing layout intact
  — no half-state with cleared subscriptions and a null root.
- BuildLayout returning null throws InvalidOperationException with a
  clear message instead of silently leaving _layoutRoot null and
  rendering blank.

Focus preservation:

- InvalidateLayout no longer unconditionally re-applies FocusPolicy.
  If the user has Tab'd to a non-default focusable AND that node still
  exists in the new tree (common case when BuildLayout reuses page-
  field nodes), focus is preserved. Only when the focused node is
  orphaned by the rebuild does the policy default re-apply, so the
  user always has somewhere to land.

Redraw trigger:

- InvalidateLayout now explicitly calls ViewModel.RequestRedraw() at
  the end. Previously the new tree was built and activated but no
  redraw was triggered until some other reactive emission happened,
  so a resize handler that did nothing but invalidate produced no
  visible change.

Documentation:

- Expanded InvalidateLayout XML remarks: now enumerates the preserved
  vs reset state (subscriptions/keybindings/focus), the build-then-
  swap guarantee, the known orphan-container leak (ContainerNode keeps
  child-invalidation subscriptions to user-held nodes until page
  Dispose — Dispose of orphan would recurse into user leaves, so we
  can't), the user-Subscriptions-targeting-BuildLayout-created-nodes
  hazard, the throw contract, and the thread affinity requirement.

Tests:

Adds 12 new tests covering every contract:
- BeforeOnNavigatedTo_IsNoOp, AfterOnNavigatingFrom_IsNoOp
- AfterDispose_Throws, OnNavigatedTo_AfterDispose_Throws,
  Dispose_IsIdempotent
- ReEntrant_Throws (via test page that re-enters from BuildLayout)
- NullBuildLayout_Throws, BuildLayoutThrows_LeavesLayoutIntact
  (verifies build-then-swap rollback)
- RequestsRedraw
- OldInvalidatingNode_DoesNotDriveRedraws,
  NewInvalidatingNode_DrivesRedraws,
  RepeatedCalls_DoNotAccumulateSubscriptions (the IInvalidatingNode
  surface the original tests never exercised — they all used TextNode
  which is NOT IInvalidatingNode)

Existing 5 tests still pass; full Termina suite 1116/1116 green.
Copy link
Copy Markdown
Owner Author

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concrete issues worth fixing before merge:\n\n1. abandons the old layout root without disposing it, but wrapper nodes such as and only detach child invalidation subscriptions in . If a page reuses a child node instance across rebuilds, those old wrapper nodes can stay alive through the child -> parent invalidation subscription chain instead of being garbage-collected.\n\n2. rebuilds the tree without reconciling focus. On pages using manual focus, can keep pointing at a control from the discarded tree, so subsequent input is routed to a stale node instead of the rebuilt layout.\n\nPlease add a regression test for the focus case as part of the fix.

Copy link
Copy Markdown
Owner Author

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concrete issues worth fixing before merge:

  1. InvalidateLayout() abandons the old layout root without disposing it, but wrapper nodes such as PanelNode and ContainerNode only detach child invalidation subscriptions in Dispose(). If a page reuses a child node instance across rebuilds, those old wrapper nodes can stay alive through the child -> parent invalidation subscription chain instead of being garbage-collected.

  2. InvalidateLayout() rebuilds the tree without reconciling focus. On pages using manual focus, IFocusManager.CurrentFocus can keep pointing at a control from the discarded tree, so subsequent input is routed to a stale node instead of the rebuilt layout.

Please add a regression test for the focus case as part of the fix.

@Aaronontheweb Aaronontheweb enabled auto-merge (squash) May 23, 2026 22:43
@Aaronontheweb Aaronontheweb merged commit 9e68036 into dev May 23, 2026
13 checks passed
@Aaronontheweb Aaronontheweb deleted the feature/reactive-page-invalidate-layout branch May 23, 2026 22:46
@Aaronontheweb Aaronontheweb mentioned this pull request May 23, 2026
Aaronontheweb added a commit that referenced this pull request May 23, 2026
- Update version to 0.10.0
- Add release notes for v0.10.0 with PRs #220, #219, #215, #217
- Update PackageReleaseNotes in Directory.Build.props
- Update RELEASE_NOTES.md with full release notes
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.

1 participant