Skip to content

Embedded browser: NewWindowRequested (target=_blank, window.open) no longer opens a new tab (regression ~2026-08-14) #1325

Description

@JoshuaRowePhantom

Embedded browser: "open in new window" no longer opens a new browser tab

Summary

In the embedded WebView2 browser, requesting a new window — via target="_blank", window.open(), middle-click, or any path that fires CoreWebView2.NewWindowRequested — no longer opens a new browser tab in the workspace. The owner reports this regressed on/after 2026-08-14. Impact: users cannot open web links in additional tabs from within the embedded browser; the click either silently does nothing or spawns an external window (defeating the point of the in-app browser).

Root Cause

The NewWindowRequested → new-tab pipeline is:

  1. Phantom.Workspaces\Controls\ConfiguredWebView.cs:89 subscribes this.NewWindowRequested += OnNewWindowRequested;

  2. ConfiguredWebView.OnNewWindowRequested (ConfiguredWebView.cs:193-231) reads the Request URI via reflection, sets args.Handled = true, then calls this.ViewModel.RaiseOpenNewWindow(urlString).

  3. WebViewModel.RaiseOpenNewWindow (Phantom.Workspaces\ViewModels\WebViewModel.cs:222-238) is:

    public async void RaiseOpenNewWindow(string url)
    {
        if (this.tabService == null)
        {
            return;             // <-- silent no-op when tabService is null
        }
    
        var newTab = new WebViewModel(url, this.tabService) { ... };
        await this.tabService.OpenTabAsync(newTab, insertAfterTabId: this.Id);
    }

Confirmed broken wiring site

The WebViewModel instances constructed on the layout-restore path are built without a tabService, so RaiseOpenNewWindow early-returns and no tab is created. All three restore call sites in Phantom.Workspaces\ViewModels\MainWindowViewModel.cs omit the tabService argument:

  • MainWindowViewModel.cs:3513 (persisted BrowserDockTabDescriptor → new tab):
    return new WebViewModel(browserDesc.Url, titleFixed: browserDesc.IsTitleExplicit)
    {
        Id = tabId, Title = ..., DockRegion = "full",
    };
  • MainWindowViewModel.cs:3565 (TryFetchWorkspaceTabAsync URL fallback):
    return new WebViewModel(url.GetString()!) { Id = ..., Title = ..., DockRegion = ... };
  • MainWindowViewModel.cs:3720 (CreateTabFromEntityAsync URL fallback): same shape.

Because WebViewModel's constructor signature is (string initialUrl, IWorkspaceTabService? tabService = null, bool titleFixed = false, IUrlOpener? urlOpener = null), omitting the second argument leaves tabService = null. As a result, every browser tab that was restored from a saved workspace layout (the vast majority of tabs the user sees on subsequent launches) silently drops NewWindowRequested.

Because ConfiguredWebView.OnNewWindowRequested does set args.Handled = true before calling RaiseOpenNewWindow, WebView2 also suppresses the default popup — so the click looks like a total no-op.

Affected Files

File Role
Phantom.Workspaces\ViewModels\MainWindowViewModel.cs Lines 3513 / 3565 / 3720 construct restored WebViewModel without passing tabService. Fix site.
Phantom.Workspaces\Controls\ConfiguredWebView.cs Subscribes NewWindowRequested, sets Handled, forwards to VM.
Phantom.Workspaces\ViewModels\WebViewModel.cs RaiseOpenNewWindow — silently returns when tabService is null.

Design / Fix

Pass this (the MainWindowViewModel, which implements IWorkspaceTabService) as the tabService argument on every layout-restore-path WebViewModel construction in Phantom.Workspaces\ViewModels\MainWindowViewModel.cs. This is the sole change required to restore the reported scenario: it re-connects NewWindowRequested → RaiseOpenNewWindow → OpenTabAsync for restored browser tabs.

// MainWindowViewModel.cs:3513
return new WebViewModel(browserDesc.Url, this, titleFixed: browserDesc.IsTitleExplicit)
{
    Id = tabId,
    Title = !string.IsNullOrEmpty(browserDesc.Title) ? browserDesc.Title : browserDesc.Url,
    DockRegion = "full",
};

// MainWindowViewModel.cs:3565
return new WebViewModel(url.GetString()!, this)
{
    Id = ReadString(tab, "tab-id") ?? url.GetString()!,
    Title = ReadString(tab, "title") ?? url.GetString()!,
    DockRegion = ReadString(tab, "dock") ?? "full",
};

// MainWindowViewModel.cs:3720 (same shape as :3565)
return new WebViewModel(url.GetString()!, this)
{
    Id = ReadString(tab, "tab-id") ?? url.GetString()!,
    Title = ReadString(tab, "title") ?? url.GetString()!,
    DockRegion = ReadString(tab, "dock") ?? "full",
};

This restores the NewWindowRequestedOpenTabAsync chain for every restored tab, matching the behavior of freshly-opened browser tabs (which already pass tabService). No other change is required to fix the reported scenario.

Considered / Background (NOT required for this fix)

The following options were considered while diagnosing this bug and are preserved here for context. They are not required to resolve the reported scenario; Option A above is sufficient.

  • B. Harden WebViewModel.RaiseOpenNewWindow against a null tabService (defensive logging / fallback resolution). This would turn a future mis-wired construction into a logged warning rather than a silent no-op, e.g.:

    public async void RaiseOpenNewWindow(string url)
    {
        var svc = this.tabService;
        if (svc is null)
        {
            Trace.TraceWarning("WebViewModel.RaiseOpenNewWindow: tabService is null for tab {0}; new-window request for {1} dropped.", this.Id, url);
            return;
        }
        ...
    }

    This is optional future robustness only and is not required for the reported scenario. It could be tracked separately if desired.

  • C. Audit WorkspaceDockFactory.CreateDocumentDock dock-Id behavior during restore (introduced by 2d330f54 / Web tabs in newly-created horizontal/vertical docks use wrong tab header template (no web icon, unconstrained width) #1307 — the override mints a fresh Guid Id on every call, which was a suspected contributor because a mismatched dock identity during restore could interfere with OpenTabAsync's dock lookup). Investigation now considers this not the cause of the reported scenario: the missing-tabService construction in Option A fully explains the observed symptom (silent no-op on restored tabs), and RaiseOpenNewWindow early-returns before any dock lookup would occur. This audit is preserved as background regression-window context only.

Verify ConfiguredWebView.OnNewWindowRequested continues to set args.Handled = true before returning on any error path, so WebView2 does not spawn a background OS window when our new-tab creation fails.

Regression window

Worked before 2026-08-14; broke on/after 2026-08-14. The user-visible scenario is caused by the missing-tabService construction on the restore path (see Root Cause). Option A fixes this regardless of which commit made the pre-existing null-tabService construction newly reachable in typical usage.

Candidate commits from the regression window (background context only — a bisect is not required to apply the fix):

  • 07579a53Fix #1310: single-source Ctrl+W handling + focus-aware CloseActiveTabCommand (2026-08-14 15:21). Removed the CloseTabRequested legacy typed event from AcceleratorAwareWebView.cs and unsubscribed from it in ConfiguredWebView.cs:97-99. The diff does not touch NewWindowRequested, but it is the largest churn on the WebView event-fan-out surface in the window.
  • 2d330f54Fix #1307: split-created document docks are WorkspaceContentDock (2026-08-14 15:25). Overrides WorkspaceDockFactory.CreateDocumentDock to mint WorkspaceContentDock instances with a fresh Guid Id on every call. See Option C above — no longer considered the cause of the reported scenario.

Expected Tests

Naming follows the existing WebViewModelTests / MainWindowIntegrationTests conventions (Subject_Scenario_ExpectedOutcome).

Test Purpose
MainWindowViewModel_RestoredBrowserTab_HasTabServiceWired Restore a persisted BrowserDockTabDescriptor and assert the resulting WebViewModel has a non-null tabService (via reflection or an internal accessor). Directly validates Option A.
MainWindowViewModel_RestoredBrowserTab_RaiseOpenNewWindow_InsertsNewTabInDock Restore a browser tab, invoke RaiseOpenNewWindow, and assert a new WorkspaceDocument appears in the pane's DocumentDock.VisibleDockables immediately right of the source.
ConfiguredWebView_OnNewWindowRequested_WhenHandlerRuns_SetsArgsHandledTrue Prevents WebView2 from opening an external OS window when we intend to route to a new in-app tab.
MainWindowIntegration_BlankTargetNavigation_OpensNewWebTabInSameWorkspacePane End-to-end: simulate a _blank navigation on a restored tab and assert a new WebViewModel tab appears in the selected pane.

Reference existing tests: Phantom.Workspaces.Tests\WebViewModelTests.cs:552 RaiseOpenNewWindow_InsertsNewTabImmediatelyRightOfSourceTab (covers the fresh-tab path only) and Phantom.Workspaces.Tests\MainWindowIntegrationTests.cs:6406 OpenTabAsync_ExistingTab_PushesNavigationEntry.

Considered / Background tests (NOT required for this fix)

  • WebViewModel_RaiseOpenNewWindow_WhenTabServiceNull_LogsAndDoesNotDropRequest — validates the demoted Option B (defensive logging in RaiseOpenNewWindow when tabService is null). Would only be added if Option B is pursued as separate future work.

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions