Skip to content

fix(explore): open version-history forks in a usable tab, not a blank one - #43830

Open
mikebridge wants to merge 1 commit into
apache:masterfrom
mikebridge:sc-119722-open-as-new-blank-tab
Open

fix(explore): open version-history forks in a usable tab, not a blank one#43830
mikebridge wants to merge 1 commit into
apache:masterfrom
mikebridge:sc-119722-open-as-new-blank-tab

Conversation

@mikebridge

Copy link
Copy Markdown
Contributor

SUMMARY

The version-history panel's "Open as new chart / Open as new dashboard" (and the related-entity open) opened a blank about:blank tab instead of the forked object — reproduced for both current and older versions, on charts and dashboards.

Root cause is in superset-frontend/src/utils/navigationUtils.ts. These flows follow a claim-then-navigate pattern: because the fork takes several sequential requests (snapshot → resolve → copy), they call openBlankTab() synchronously in the click handler — while the click's transient user activation is still live — and then point that tab at the destination once the new object's id is known, via navigateOpenedTab(). This avoids the popup blocker refusing a window.open issued after the awaits.

But openBlankTab() opened its placeholder with window.open('', '_blank', 'noopener noreferrer'), and per the HTML standard window.open(..., 'noopener') always returns null — that is the entire purpose of noopener: sever the opener link, so the caller gets no window handle. So the handle the function exists to return was discarded on every call:

  1. openBlankTab() opens a blank tab but returns nulltab = null.
  2. The fork runs (snapshot fetch, uuid resolve, POST /copy/ or POST /chart/) — all succeed; the server creates the new object.
  3. navigateOpenedTab(null, url) sees a null handle and falls through to a second window.open(url, ...), which by now has lost user activation and is silently refused by the popup blocker.
  4. The blank tab from step 1 is stranded on about:blank.

The fix opens the placeholder without noopener so the returned handle is usable, and navigateOpenedTab can call tab.location.replace(url) on the live tab. The destination is always a same-origin app route (built through ensureAppRoot and validated by assertSafeNavigationUrl), so the opener relationship carries no cross-origin tabnabbing risk. The one-shot window.open(url, ...) fallback in navigateOpenedTab keeps noopener, since it passes the real URL directly and never needs the handle.

Scope: this fixes every claim-then-navigate path — chart open-as-new, dashboard open-as-new, and openRelatedEntity (all share openBlankTab / navigateOpenedTab). In-place Preview was never affected because it renders in the panel and opens no tab.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Before: selecting "Open as new chart/dashboard" from the version-history kebab opens a new tab that stays on about:blank — even though the fork succeeded on the server (the copy request returns 200 with the new id). Verified live: POST /api/v1/dashboard/<id>/copy/200 {"result":{"id":N}}, and navigating directly to /dashboard/N/ renders the fork correctly — only the automatic tab navigation was broken.

After: the claimed tab receives a real window handle and is navigated to the new object's route (/explore/?slice_id=N or /dashboard/N/), so the forked chart/dashboard opens populated, as intended.

TESTING INSTRUCTIONS

Requires the versioning UI (SOFT_DELETE / version-history feature) enabled and an object with at least one saved version.

  1. Open a chart in Explore (or a dashboard) → 3-dot menu → View version history.
  2. On a version's kebab → Open as new chart / Open as new dashboard.
  3. Before this fix: a new tab opens and stays blank. After: the new tab opens the forked chart/dashboard, populated from that version.
  4. Repeat for the current version and an older version.

Automated: cd superset-frontend && npx jest src/utils/navigationUtils.test.ts — adds coverage for openBlankTab (returns a usable handle, no noopener), navigateOpenedTab (live-handle replace with app-root prefix, URL validation before touching the tab, null/closed → window.open fallback), and closeOpenedTab.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

@bito-code-review

bito-code-review Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #1c196c

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 2e73786..2e73786
    • superset-frontend/src/utils/navigationUtils.test.ts
    • superset-frontend/src/utils/navigationUtils.ts
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.42%. Comparing base (a6cbd7c) to head (72152ba).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #43830   +/-   ##
=======================================
  Coverage   79.41%   79.42%           
=======================================
  Files        2895     2895           
  Lines      167939   167942    +3     
  Branches    38894    38896    +2     
=======================================
+ Hits       133375   133389   +14     
+ Misses      32064    32053   -11     
  Partials     2500     2500           
Flag Coverage Δ
javascript 74.95% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mikebridge
mikebridge force-pushed the sc-119722-open-as-new-blank-tab branch from 2e73786 to 98fdcd1 Compare September 3, 2026 19:56
@mikebridge

Copy link
Copy Markdown
Contributor Author

Ran a 2-lens internal review (React/TypeScript + a structured PR-style pass) over this diff — both approved, no blocking findings. Folded the actionable notes into 98fdcd1963:

  • Structural same-origin enforcement in navigateOpenedTab. Dropping noopener from the about:blank placeholder means the claimed tab now carries an opener link. That's safe for the two callers here (both pass relative /explore/… and /dashboard/… paths), but assertSafeNavigationUrl also permits safe absolute URLs, so the same-origin property was caller convention rather than enforced. navigateOpenedTab now reuses the opener-connected tab only for a same-origin route; an absolute/external URL closes the claimed tab and reopens through the noopener fallback — so no destination ever rides the opener chain. Regression test added.
  • Added a test asserting openBlankTab returns null on a blocked popup (the signal callers rely on), and made the new test block's window.open spy default to a no-op for consistency with the rest of the file.

File is at 82/82 tests, changed-file pre-commit clean (oxfmt/oxlint/tsc).

@rebenitez1802 rebenitez1802 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve — correct, minimal, and spec-verified. Nice work.

The root-cause analysis is exactly right: per the WHATWG spec window.open(..., 'noopener') always returns null (and noreferrer implies noopener), so on master openBlankTab() always returned null and the tab.location.replace reuse branch in navigateOpenedTab was effectively dead code — the gesture-authorized placeholder tab could never actually be reused. Dropping the features string on the placeholder is what makes the claim-then-navigate pattern work, and gating reuse behind isSameOriginRoute upgrades the same-origin guarantee from a caller convention into a structural invariant. Since assertSafeNavigationUrl(ensureAppRoot(path)) already constrains url to either a single-slash router-relative path or an absolute allow-listed scheme, and the app root is always '' or /segment, the guard is both sound and complete — no cross-origin URL can ever ride the opener-connected tab, so dropping noopener here is a neutral-to-positive security change. Test 7 encoding that same-origin/tabnabbing invariant as an executable assertion is a great touch.

One cleanup I'd ask for before merge, plus two optional test nits:

🟡 Please remove the internal tracker reference. One of the new tests carries a comment referencing an internal, non-Apache ticket ID (and the branch name embeds the same ID). Internal tracker references shouldn't land in the permanent public history via the squashed commit. Could you genericize the comment to describe the regression itself, e.g. // Regression: opening as new stranded a blank about:blank tab because openBlankTab returned null?

🟢 openBlankTab test would stay green on a noreferrer-only regression. The placeholder test only inspects the noopener token. Because noreferrer also forces window.open to return null in a real browser, re-introducing just 'noreferrer' would reship the stranded-tab bug while the test (whose mock returns a tab unconditionally) stays green. Consider asserting the exact call instead: expect(openSpy).toHaveBeenCalledWith('', '_blank').

🟢 URL-validation-ordering test under-pins itself. validates the URL before touching a claimed tab asserts replace/open weren't called, but not close — so a future refactor moving tab.close() ahead of assertSafeNavigationUrl wouldn't be caught. Consider adding expect(tab.close as jest.Mock).not.toHaveBeenCalled();.

🟢 Dead-but-defensive external branch (informational). The !isSameOriginRoutetab.close() + window.open(...) path is unreachable for all current callers (they all pass router-relative routes). Good hardening to keep; just note that if a future caller ever passes an absolute URL after an await, the reopen will be popup-blocked and the placeholder closes with nothing opening. A one-line docstring caveat would help the next reader.

… one

openBlankTab opened its placeholder with window.open('', '_blank',
'noopener noreferrer'). Per the HTML standard, window.open(..., 'noopener')
always returns null -- so the handle the function exists to return was
discarded on every call. The version-history "Open as new chart/dashboard"
and "open related entity" flows claim a tab up front (while the click's
transient activation is live), then navigate it after awaiting the fork
requests. With a null handle, navigateOpenedTab always fell through to a
one-shot window.open(url) whose activation had lapsed, so the browser
refused it and left the user staring at a stranded about:blank tab -- even
though the fork itself succeeded on the server.

Open the placeholder without noopener so the handle is usable. The
destination is always a same-origin app route (ensureAppRoot), so the
opener relationship carries no cross-origin tabnabbing risk; the one-shot
fallback in navigateOpenedTab keeps noopener since it passes the real URL
and never needs the handle.

Adds coverage for openBlankTab (handle returned, no noopener),
navigateOpenedTab (live-handle replace with app-root prefix, URL validation
before touching the tab, null/closed fallback to window.open), and
closeOpenedTab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQeAcprGvkFS8F3nghtvwv
@mikebridge
mikebridge force-pushed the sc-119722-open-as-new-blank-tab branch from 98fdcd1 to 72152ba Compare September 3, 2026 22:51
@mikebridge

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review @rebenitez1802 — all addressed in 72152ba81a:

  • 🟡 Removed the internal tracker reference. Genericized the test comment to describe the regression itself (// Regression: opening a version as new stranded a blank about:blank tab because window.open(..., 'noopener') returns null…); no non-Apache IDs remain in the diff, and the squashed commit body is already clean of them.
  • 🟢 openBlankTab now asserts the exact call. Replaced the noopener-token check with expect(openSpy).toHaveBeenCalledWith('', '_blank'), so re-introducing noreferrer alone (which also nulls the handle) now fails the test.
  • 🟢 URL-validation-ordering test pins close too. Added expect(tab.close).not.toHaveBeenCalled(), so moving tab.close() ahead of assertSafeNavigationUrl would be caught.
  • 🟢 Documented the defensive external branch. Added a docstring caveat on navigateOpenedTab noting the !isSameOriginRoute path is unreachable for current callers and that a post-await external target would be popup-blocked.

82/82 on the file, changed-file pre-commit clean (oxfmt/oxlint/tsc).

@bito-code-review

bito-code-review Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #b5539c

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset-frontend/src/utils/navigationUtils.ts - 1
    • CWE-1022: Reverse Tabnabbing · Line 480-480
      `openBlankTab` leaves `window.opener` intact on the blank tab. The comment's justification (destination is always same-origin) misses that the risk is the await window: if the user navigates the `about:blank` tab to an external site before `navigateOpenedTab` runs, that page gains `window.opener` access to the Superset page. Set `tab.opener = null` after opening to sever the link while keeping the handle. ([CWE-1022](https://cwe.mitre.org/data/definitions/1022.html))
Review Details
  • Files reviewed - 2 · Commit Range: 72152ba..72152ba
    • superset-frontend/src/utils/navigationUtils.test.ts
    • superset-frontend/src/utils/navigationUtils.ts
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • Eslint (Linter) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rebenitez1802 rebenitez1802 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants