Skip to content

fix(browser): reparent webviews into a single active host#241

Merged
yonaries merged 2 commits intomainfrom
fix/single-host-tab-reparenting-main
Mar 16, 2026
Merged

fix(browser): reparent webviews into a single active host#241
yonaries merged 2 commits intomainfrom
fix/single-host-tab-reparenting-main

Conversation

@yonaries
Copy link
Copy Markdown
Contributor

Summary

  • replace the multi-mounted browser tab stack with a single active host view
  • reparent instances in place and retarget mouse/focus handling to the active page
  • add unit tests and project wiring for the host reparenting behavior

Verification

  • xcodebuild -project Ora.xcodeproj -scheme ora -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/ora-derived CODE_SIGNING_ALLOWED=NO test
  • ./scripts/xcbuild-debug.sh

@yonaries yonaries requested a review from kenenisa as a code owner March 16, 2026 07:01
@tembo tembo bot added the bug Something isn't working label Mar 16, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 16, 2026

Greptile Summary

Replaces the previous multi-mounted ZStack+ForEach tab rendering strategy with a single-host reparenting approach. Instead of keeping multiple BrowserPageView instances alive in the SwiftUI view hierarchy (one per recent/special tab, toggled via opacity), the PR introduces BrowserPageHostView — an NSView container that reparents the active tab's WKWebView in place, transferring first-responder status and retargeting the mouse-event coordinator.

  • BrowserPageHostView handles attaching, detaching, and reparenting NSView content views with proper layout and first-responder management
  • BrowserSplitView simplified from a ZStack with ForEach over tabsToRender to a Group that renders only the active tab or HomeView
  • .id(tab.id) removed from BrowserPageView so SwiftUI reuses the host and triggers updateNSView for reparenting instead of recreating the NSView
  • tabsToRender computed property removed from TabManager (no remaining references)
  • Mouse event coordinator refactored to register the monitor once at init and retarget page/contentView via update() calls
  • Unit tests added for all core BrowserPageHostView behaviors, and test target properly wired into project.yml

Confidence Score: 4/5

  • This PR is safe to merge — the architectural change is well-reasoned and the implementation is solid with good test coverage for the new component.
  • Score of 4 reflects a clean, well-tested architectural improvement. The reparenting logic is correct, the removal of tabsToRender has no orphaned references, and unit tests cover the key behaviors. Minor style considerations around the dual-removal pattern and global event monitor lifetime prevent a 5.
  • ora/Core/BrowserEngine/BrowserPageView.swift — contains the new BrowserPageHostView reparenting logic and the refactored event coordinator; most of the complexity lives here.

Important Files Changed

Filename Overview
ora/Core/BrowserEngine/BrowserPageView.swift Introduces BrowserPageHostView for reparenting WKWebViews and refactors BrowserPageView to use single-host pattern with coordinator retargeting. Clean implementation with proper first-responder transfer and layout management.
ora/Features/Browser/Views/BrowserSplitView.swift Simplifies content view from a ZStack with ForEach over tabsToRender to a Group that conditionally renders either the active tab or HomeView. Clean simplification.
ora/Features/Browser/Views/BrowserWebContentView.swift Removes .id(tab.id) from BrowserPageView to enable NSView reuse across tab switches — essential for the reparenting strategy.
ora/Features/Tabs/State/TabManager.swift Removes now-unused tabsToRender computed property. No remaining references in the codebase.
oraTests/BrowserPageHostViewTests.swift Well-structured unit tests covering core BrowserPageHostView behaviors: attach, switch, no-op, clear, and cross-host reparenting.
project.yml Adds oraTests unit test target and wires it into the ora scheme's test action.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[BrowserSplitView.contentView] -->|activeTab exists| B[BrowserContentContainer]
    A -->|no activeTab| H[HomeView]
    B --> C[BrowserWebContentView]
    C -->|tab.browserPage exists| D[BrowserPageView]
    D -->|makeNSView / updateNSView| E[BrowserPageHostView]
    E -->|host contentView| F{Same content view & superview?}
    F -->|Yes| G[No-op early return]
    F -->|No| I[Remove previous content view]
    I --> J[Reparent new WKWebView into host]
    J --> K[Set frame, layout, display]
    K --> L{First responder was in old view?}
    L -->|Yes| M[Transfer first responder to new view]
    L -->|No| N[Done]
    D -->|Coordinator.update| O[Retarget page & contentView refs]
    O --> P[Global mouse monitor uses latest refs]
Loading

Last reviewed commit: 0819e75

Comment on lines +18 to +61
func host(contentView newContentView: NSView?) {
let previousContentView = hostedContentView
let isSameContentView = previousContentView === newContentView

if isSameContentView, newContentView?.superview === self {
return
}

let shouldRestoreFirstResponder = shouldTransferFirstResponder(from: previousContentView)

previousContentView?.removeFromSuperview()
hostedContentView = nil

guard let newContentView else {
needsLayout = true
layoutSubtreeIfNeeded()
displayIfNeeded()
return
}

configure(contentView: newContentView)

if newContentView.superview !== self {
if let previousHost = newContentView.superview as? BrowserPageHostView {
previousHost.hostedContentView = nil
}
newContentView.removeFromSuperview()
addSubview(newContentView)
}

newContentView.frame = bounds
hostedContentView = newContentView

needsLayout = true
layoutSubtreeIfNeeded()
newContentView.needsLayout = true
newContentView.layoutSubtreeIfNeeded()
newContentView.needsDisplay = true
displayIfNeeded()

if shouldRestoreFirstResponder {
window?.makeFirstResponder(newContentView)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant removeFromSuperview when switching to a different content view

When the previous and new content views are different, previousContentView?.removeFromSuperview() on line 28 removes the old view. Then later at line 40-46, the code checks newContentView.superview !== self and removes newContentView from its current parent before adding it. However, if both previousContentView and newContentView happen to be children of self (e.g. if a stale hostedContentView was left behind from an earlier bug), removeFromSuperview() would be called twice on the same view in a single pass — once as previousContentView and once inside the superview !== self block if addSubview was somehow skipped. In practice the current code paths prevent this, but the dual-removal pattern is fragile.

More concretely: when previousContentView !== newContentView and newContentView.superview is another BrowserPageHostView, previousHost.hostedContentView is correctly nilled out. But the previous host's own host(contentView:) won't be called to clean up any layout/display state it may have queued — it just silently loses its reference. This is likely fine today since the old host will be deallocated, but could be a subtle source of stale-state bugs if hosts are ever reused.

Comment on lines +145 to 146
private func startMouseEventMonitoring() {
mouseEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.otherMouseDown]) { [weak self] event in
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Global event monitor lives for coordinator's entire lifetime

NSEvent.addLocalMonitorForEvents is an app-wide listener. The monitor is registered once in init() and only removed in deinit. Because the coordinator's page and contentView are weak and retargeted on each SwiftUI update, the monitor effectively listens for all otherMouseDown events across the app for its entire lifetime.

If multiple BrowserPageView instances are ever alive simultaneously (e.g. during split-view or if SwiftUI keeps a stale coordinator), each coordinator will run isEventInContentView on every mouse event. This is unlikely to cause correctness issues (the hit-test guards are solid), but is worth noting for performance if the number of coordinators scales.

@yonaries yonaries force-pushed the fix/single-host-tab-reparenting-main branch from 798b36c to ea2a342 Compare March 16, 2026 08:06
@yonaries yonaries merged commit 27dfe88 into main Mar 16, 2026
2 checks passed
@yonaries yonaries deleted the fix/single-host-tab-reparenting-main branch March 16, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant