Skip to content

refactor: shared portal helpers with divergences as explicit policy - #255

Merged
arzafran merged 14 commits into
mainfrom
refactor/portal-shared-helpers
Aug 4, 2026
Merged

refactor: shared portal helpers with divergences as explicit policy#255
arzafran merged 14 commits into
mainfrom
refactor/portal-shared-helpers

Conversation

@arzafran

@arzafran arzafran commented Aug 4, 2026

Copy link
Copy Markdown
Member

What this does

Fourth of five PRs from today's maintainability audit (finding N4, executing the planner design). The browser and terminal window portals reimplemented the same machinery — teardown loops, geometry-notification registration, at-point hit scans, dead-entry pruning, transient-recovery retry — as five near-identical copies each. Those five helpers now live once on the shared HostedViewPortalRegistry base, and the two REAL behavioral divergences become explicit, tested policy instead of buried conditionals:

  • pruning: terminal treats a nil anchor as dead; browser deliberately keeps it (avoids WebKit reload storms across workspace switches) — now named isDead closures, each pinned by a test
  • retry reset: terminal resets when the budget exhausts, browser when the failure reason changes — now a two-case TransientRecoveryResetPolicy enum on a pure, fixture-free function with both cases tested

Deliberately NOT shared (majority-bespoke, out of scope per plan): ensureInstalled, bind, synchronizeHostedView/WebView, scheduleExternalGeometrySynchronize. The audit's 800-1200-line estimate counted superficial similarity; the honest yield is the helpers + the divergence-as-data clarity. The inverse z-order placement rules between the two portals were verified as the deliberate stacking contract, not drift.

14 commits, browser-first per helper (lower blast radius proves each helper before it reaches the terminal hot path), every commit builds green.

Review order

  1. HostedViewPortalRegistry.swift (+134: the five helpers)
  2. The H4/H5 commits (pruning + retry policy — the two with real semantics)
  3. The pin tests in both lifecycle test files

Test plan

  • CI green including tests-build-and-lag (portal geometry is latency-adjacent)
  • TerminalWindowPortalLifecycleTests + BrowserWindowPortalLifecycleTests + TransientRecoveryRetryStateTests: 41/41
  • WindowAndDragTests.swift full file: 55/55
  • Manual: split churn, window move, DevTools dock/undock, Finder file-drop onto both pane types, workspace switch-away-and-back with a browser pane (the nil-anchor case)

arzafran added 14 commits August 4, 2026 18:00
…une behavior

Adds the shared TransientRecoveryRetryState/scheduleRetryIfNeeded pure
abstraction (H5 of the portal-registry dedup) with unit tests covering
both reset policies, plus a new test pinning WindowBrowserPortal's
deliberate nil-anchor-preserves-entry behavior so a later shared
pruneEntries migration can't accidentally harmonize it with Terminal's
opposite (nil-anchor-is-dead) policy.
Adds tearDownEntries, observeGeometryAffectingNotifications (+
GeometryNotificationSpec), hitScanReversedSubviews, and the generic
pruneEntries skeleton to HostedViewPortalRegistry. Nothing calls these
yet — WindowTerminalPortal and WindowBrowserPortal are wired to them in
follow-up commits, one helper at a time.
remaining==0 always grants a fresh budget under whenExhausted,
regardless of reason (Terminal doesn't track a reason at all) — my
initial assertion had this backwards.
Browser side of the H1 migration. Behavior-preserving: same
removeGeometryObservers-then-detach-each-id order, just via the shared
base-class loop shape.
… helper (H2)

Browser side of the H2 migration: the 4-notification list (didResize,
didEndLiveResize, NSSplitView.willResizeSubviews, didResizeSubviews
gated through shouldTreatSplitResizeAsExternalGeometry) is passed as
data to observeGeometryAffectingNotifications instead of repeating the
addObserver/MainActor.assumeIsolated boilerplate per notification.
Handler bodies are unchanged.
Terminal side of the H1 migration. Behavior-preserving: same
removeGeometryObservers-then-detach-each-id order, plus Terminal's own
NSLayoutConstraint cleanup and hostView removal unchanged around it.
…d helper (H2)

Terminal side of the H2 migration: the 5-notification list (didResize,
didEndLiveResize, NSSplitView.didResizeSubviews gated on window match,
hostView frameDidChange, hostView boundsDidChange) is passed as data to
observeGeometryAffectingNotifications instead of repeating the
addObserver/MainActor.assumeIsolated boilerplate per notification.
Handler bodies are unchanged.
…ton (H3)

Browser side of the H3 migration. Same reversed-subviews scan;
map casts to WindowBrowserSlotView and checks isHidden, resolve does
the frame-containment check plus the containerView->webView lookup.
…n (H3)

Terminal side of the H3 migration. Same reversed-subviews scan;
map casts to GhosttySurfaceScrollView and checks the entries dict plus
isHidden, resolve does the frame-containment check plus the existing
hitTest fallback. terminalViewAtWindowPoint (no Browser counterpart)
is left untouched.
…ton (H4)

Browser side of the H4 migration. isDead closure preserves the
deliberate nil-anchor-is-NOT-dead policy (keeps a hidden WKWebView
alive across workspace switches instead of forcing a WebKit reload
storm) plus the container-orphan signal
(container.superview == nil || !isDescendant(of: hostView)). The
reverse-map reconciliation pass after pruning is unchanged.
…eton (H4)

Terminal side of the H4 migration. isDead closure preserves the
existing policy: fully deallocated anchor is dead; anchor merely
off-tree-for-this-host is dead only when the entry isn't currently
visible. The reverse-map reconciliation pass after pruning is
unchanged.
…yState (H5)

Browser side of the H5 migration. scheduleTransientRecoveryRetryIfNeeded
now delegates its counter/reason bookkeeping to the shared, pure
scheduleRetryIfNeeded using the .whenReasonChanges policy (a new retry
reason gets its own full budget) — preserving the exact original
behavior, now backed by the unit-tested pure function. entry field
writes and the deferred-full-sync side effect are unchanged.
…yState (H5)

Terminal side of the H5 migration. scheduleTransientRecoveryRetryIfNeeded
now delegates its counter bookkeeping to the shared, pure
scheduleRetryIfNeeded using the .whenExhausted policy (Terminal has no
reason field — reset only when the budget is fully spent) — preserving
the exact original behavior, now backed by the unit-tested pure
function.
…test

anchor2 was allocated after anchor1 was dropped, so ARC could reuse
anchor1's freed address for anchor2 — aliasing the stale
webViewByAnchorId[anchor1] entry and tripping bind()'s unrelated
"anchor replaced" detach path (which detaches webView1) instead of
exercising pruneDeadEntries' nil-anchor policy. Allocating anchor2
up front avoids the collision.
@arzafran
arzafran merged commit f305744 into main Aug 4, 2026
10 checks passed
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