Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

NSViewRepresentable and NSHostingView: Who Owns the Object

~16 min · appkit-swiftui, nsviewrepresentable, nshostingview, lifetimes, wkwebview

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"No page loaded, no delegate callback fired, no error was set. There was no web view at all."

Two Bridges, Two Directions

  • NSHostingView / NSHostingController put a SwiftUI view inside AppKit. The AppKit side owns the hosting view's lifetime, exactly like any other NSView. This direction rarely surprises anyone.
  • NSViewRepresentable puts an AppKit view inside SwiftUI. SwiftUI calls makeNSView when the representable first appears in the view tree, updateNSView when its inputs change, and tears the view down when the representable leaves the tree. A Coordinator holds delegate state that should outlive individual updates.

That second direction has a consequence that is easy to forget: the AppKit object's lifetime belongs to SwiftUI's view tree. If the representable is behind an if, the object does not exist whenever the condition is false.

The Preview Pane That Was Never Built

A Mac agent client had a dock with a web preview pane. A headless smoke test drove it through the pane's model — bind a workspace, choose the tool, call openWorkspace(relativePath:) — and waited for the page to load. It waited forever. The pane sat behind a view-level gate that showed a placeholder when no session was bound, and the only place the WKWebView was created was inside that pane's makeNSView, which also handed the view to the model. With the pane unrendered, the model's weak var webView stayed nil, and webView?.load(…) succeeded at doing nothing.

Two fixes, both needed. The smoke test satisfies the real gate the way a person would — it opens a session — instead of poking the model behind the view's back. And it waits on an explicit isAttached before navigating, with a deadline that names the failure. A useful discriminating probe came out of it too: point the same smoke at an unreachable URL. If a web view exists, a failed-navigation callback arrives within a second; silence proves there is no web view.

The Gate Must Be the Model's Truth

A related bug hid in the same dock. The Files and Terminal panes showed "start a session first" whenever the model's local root was nil — but a remote session leaves that field nil by design, so the remote dock never rendered on the Mac, while every model-level test passed. The fix gave the model one readiness predicate (local root or remote session) and made the view call a static function of the model to decide whether to show the placeholder. The decision now lives in a function the tests can call and the view body cannot bypass.

Code

The model knows whether its view exists, and the gate is the model's own predicate·swift
import SwiftUI
import WebKit

@MainActor
@Observable
final class PreviewModel {
    var workspaceRoot: URL?
    var remoteSession: String?
    fileprivate(set) weak var webView: WKWebView?

    var isBound: Bool { workspaceRoot != nil || remoteSession != nil }   // one readiness predicate
    var isAttached: Bool { webView != nil }

    static func showsPlaceholder(_ model: PreviewModel) -> Bool { !model.isBound }

    enum PreviewError: Error { case paneNotAttached }

    func open(_ url: URL) throws {
        guard let webView else { throw PreviewError.paneNotAttached }  // not a silent no-op
        webView.load(URLRequest(url: url))
    }

    fileprivate func attach(_ view: WKWebView) { webView = view }
}

struct PreviewPane: View {
    let model: PreviewModel
    var body: some View {
        if PreviewModel.showsPlaceholder(model) {
            ContentUnavailableView("Open a workspace first", systemImage: "folder")
        } else {
            WebViewHost(model: model)
        }
    }
}

struct WebViewHost: NSViewRepresentable {
    let model: PreviewModel
    func makeNSView(context: Context) -> WKWebView {
        let view = WKWebView(frame: .zero)
        model.attach(view)            // the ONLY place the web view comes into existence
        return view
    }
    func updateNSView(_ nsView: WKWebView, context: Context) {}
}

External links

Exercise

Add PreviewModel, PreviewPane and WebViewHost to Spark's Mac target. Write a test that creates the model without rendering the pane and calls open(_:) — confirm it throws paneNotAttached instead of doing nothing. Then write a unit test for PreviewModel.showsPlaceholder covering: no session, a local root, and a remote session with no local root. Explain in one sentence why the third case is the one that was broken in the family's app.
Hint
The placeholder test needs no view inspection library: the decision is a static function of the model, so the test simply sets the fields and calls it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.