Files
obsidian-vault/work/tech-design/data-url-new-tab-frame-load-interrupted.md

10 KiB

title, created, tags, status
title created tags status
Tech Design: data: URL New-Tab Race Condition and Popup Blocking 2026-06-26
tech-design
apple
macos
navigation
webkit
data-url
popup
tab
draft

Author: Apple team Project: macOS browser — Tab navigation / Popup handling


Background & Requirements

The DuckDuckGo macOS browser uses WKWebView for page rendering. All navigations flow through DistributedNavigationDelegate (BrowserServicesKit). Tabs are modelled by Tab.swift which owns a @Published var content: TabContent property that drives what is displayed in the address bar and tab strip.

WebKit security restriction: data: URLs in main-frame navigations

WebKit deliberately blocks data: URLs from loading as a main-frame (top-level) document unless the load was initiated programmatically by the embedding app (isRequestFromClientOrUserInput = true). This is enforced in DocumentLoader::disallowDataRequest() (WebCore):

// WebCore/loader/DocumentLoader.cpp
bool DocumentLoader::disallowDataRequest() const {
    if (!m_response.url().protocolIsData())
        return false;
    // Allow if: not main frame, OR app-initiated, OR settings override
    if (!frame()->isMainFrame() || allowsDataURLsForMainFrame()
            || frame()->settings().allowTopNavigationToDataURLs())
        return false;
    // Block: add console error and return true
    return true;
}

Where allowsDataURLsForMainFrame() returns m_isRequestFromClientOrUserInput. For navigations originating from a web page (link click, window.open(), <a target=_blank>), this flag is false, so data: URL top-level navigations are always blocked.

When blocked, DocumentLoader::stopLoadingForPolicyChange() calls cancelMainResourceLoad(interruptedForPolicyChangeError()), which produces a WKError with domain WebKitErrorDomain, code WebKitErrorFrameLoadInterruptedByPolicyChange — the "Frame load interrupted" error that surfaces in webView(_:didFailProvisionalNavigation:withError:) and then Tab.navigation(_:didFailWith:).

Two paths for web-page-opened windows

When a web page calls window.open(dataURL) or navigates a link with target=_blank, the browser must decide what to do:

Path Mechanism When used
Popup window WKUIDelegate.webView(_:createWebViewWith:for:windowFeatures:) returns a new WKWebView window.open() with width/height set (or any dimensions in WKWindowFeatures)
New tab Same delegate called; app returns nil and opens a new tab independently window.open() / target=_blank without dimensions

In both cases WebKit ends up calling continueLoadAfterNewWindowPolicy in the web process, creating a new frame, and loading the data: URL into it — which triggers the disallowDataRequest() block and the interruptedForPolicyChangeError in both scenarios.


Problem Statement

Two independent bugs:

Bug 1 — New tab: stale data: URL displayed in address bar after interrupted load. When a data: URL is opened in a new tab, Tab.navigation(_:didFailWith:) receives isFrameLoadInterrupted = true and previously returned early without synchronising Tab.content with webView.url. Because new-tab display is deferred by the tab-creation flow, the webView.url reset (nil after the interrupted load) propagated to Tab.content after the tab was first shown. This caused a race that left the address bar showing the data: URL even though the navigation had been cancelled.

Bug 2 — Popup: data: URLs could still be opened. For popup windows (with dimensions), createChildWebView returned a WKWebView to WebKit. The data: load was interrupted by WebKit and interruptedForPolicyChangeError was delivered, but because the popup WebView was displayed immediately on creation, Tab.content was already populated with the data: URL before the error arrived. The popup appeared to "open" (empty/blank page) and the address bar showed the data: URL. Additionally, PopupHandlingTabExtension.createChildWebView blocked javascript: URLs but not data: URLs.


Fix 1 — Tab.navigation(_:didFailWith:): call handleUrlDidChange() on isFrameLoadInterrupted

In Tab.swift, the navigation(_:didFailWith:) handler has an early-exit guard for errors that indicate the navigation was intentionally cancelled (user stop or policy interruption). Before the fix:

guard !error.isNavigationCancelled,
      !error.isFrameLoadInterrupted else {
    return  // ← early return, Tab.content not updated
}

After:

guard !error.isNavigationCancelled,
      !error.isFrameLoadInterrupted /* navigation cancelled by a Navigation Responder or Content Blocker */ else {
    // Update tab content to the current URL to avoid race conditions with
    // WebView.url publisher that may cause `reloadIfNeeded` to reload the same URL again.
    handleUrlDidChange()
    return
}

handleUrlDidChange() reads webView.url synchronously and sets self.content accordingly. Since webView.url is nil (or the previous page's URL) after an interrupted load, this immediately corrects Tab.content before any async publisher update can race.

Why this is safe for other callers of the guard:

  • isNavigationCancelled (user pressed Stop): webView.url already holds the previous committed URL. handleUrlDidChange() will either no-op (content already matches) or restore the correct content.
  • isFrameLoadInterrupted (policy block, e.g. content blocker, data: URL): same reasoning — webView.url reflects the last committed URL, not the blocked candidate.

Fix 2 — PopupHandlingTabExtension.createChildWebView: block data: URLs

Adding data: to the list of blocked navigational schemes alongside javascript::

// Before
guard navigationAction.request.url?.navigationalScheme != .javascript else { return nil }

// After
guard ![.javascript, .data].contains(navigationAction.request.url?.navigationalScheme) else { return nil }

This prevents createChildWebView from returning a WKWebView to WebKit at all for data: URLs. WebKit interprets a nil return from createWebViewWith as "don't create a new window", so the navigation is silently dropped before a new tab or popup is ever presented to the user. No interruptedForPolicyChangeError reaches Tab.navigation(_:didFailWith:) at all.

Implementation steps

  1. macOS/DuckDuckGo/Tab/Model/Tab.swift — in navigation(_:didFailWith:), add handleUrlDidChange() call before the return in the isFrameLoadInterrupted guard branch. Done.
  2. macOS/DuckDuckGo/Tab/TabExtensions/PopupHandlingTabExtension.swift — extend the navigational-scheme guard to include .data. Done.

Notes

[1] The root disallowDataRequest() block in WebKit is intentional and correct — it prevents phishing via data: URL spoofing. We must not work around it. Our fixes address the symptom (incorrect Tab.content state and incomplete blocking) rather than trying to make data: loads succeed.

[2] handleUrlDidChange() is already called from the webView.publisher(for: \.url) observation path (Tab.swift ~line 1215). Adding it inside navigation(_:didFailWith:) makes the same update happen eagerly, eliminating the race between the Combine publisher delivery and the reloadIfNeeded pass.

[3] The popup-window race (Bug 2) exists because popup tabs are displayed synchronously at WebView-creation time (before any navigation completes), while new tabs are deferred until the navigation policy decision resolves. Fix 1 closes the race for both paths; Fix 2 eliminates the root cause for popup windows specifically.

[4] The javascript: URL guard in createChildWebView predates this change. data: should always have been included alongside it — both schemes are opaque, both produce navigation interruptions when loaded as main-frame top-level documents, and neither is a meaningful URL to display in a new tab opened by web content.


Testing

  • Manual regression (macOS):

    • Open a page that calls window.open('data:text/html,<h1>Hello</h1>', '_blank') (no dimensions). Verify no new tab appears and no stale data: URL is shown in any existing tab's address bar.
    • Same test with a popup: window.open('data:text/html,<h1>Hello</h1>', '', 'width=400,height=300'). Verify no popup window is created.
    • Open a page that calls window.open('javascript:void(0)', '_blank'). Verify unchanged — still blocked.
    • Normal window.open('https://example.com', '_blank') continues to open in a new tab.
  • New unit tests needed: PopupHandlingTabExtensionTests currently has no tests for the navigational-scheme guard in createChildWebView. Tests should be added to verify:

    • createWebView returns nil for javascript: URLs.
    • createWebView returns nil for data: URLs.
    • createWebView returns a non-nil WKWebView for HTTPS URLs when popup permission is granted.
  • Tab content state test: verify that after a navigation to a data: URL is interrupted, Tab.content reflects the previous committed URL (or .none) rather than the blocked data: URL.


Additional Considerations

Privacy

  • No user data is collected or transmitted. Both changes are local, synchronous, and in-process.
  • Blocking data: URLs from being opened in new windows is consistent with WebKit's own security model.

Security

  • data: URLs opened by web content as top-level navigations are a known phishing vector (they can impersonate login pages without any domain). The existing WebKit block (disallowDataRequest) already prevents the load; these fixes ensure our app-level state machine does not misrepresent that a load succeeded.
  • javascript: URLs are similarly dangerous as top-level navigations; they are already blocked.

Site Breakage

  • Legitimate content that opens popup windows or new tabs with data: URLs as the primary content does not exist in practice. The change only affects URLs whose navigational scheme is data: — HTTP/HTTPS, blob:, and all other schemes are unaffected.
  • The handleUrlDidChange() addition is a no-op when webView.url already matches Tab.content; it cannot cause a double-reload.

Operational

  • No infrastructure, persistence, or network changes. Fix is entirely in the tab management layer.