You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/07/01 18:20:21 UTC

(superset) branch master updated: refactor(frontend): centralize subdirectory URL prefixing behind nav helpers (#39925)

This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new bed1034c2f7 refactor(frontend): centralize subdirectory URL prefixing behind nav helpers (#39925)
bed1034c2f7 is described below

commit bed1034c2f7909d172ba495d18f09bd2f66f1ac5
Author: Joe Li <jo...@preset.io>
AuthorDate: Wed Jul 1 11:20:13 2026 -0700

    refactor(frontend): centralize subdirectory URL prefixing behind nav helpers (#39925)
    
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
    Co-authored-by: Evan <ev...@preset.io>
---
 UPDATING.md                                        |  18 +
 .../cypress-base/cypress/utils/urls.ts             |   9 +-
 superset-frontend/package-lock.json                |  72 +-
 .../src/chart/clients/ChartClient.ts               |   4 +-
 .../src/chart/components/StatefulChart.tsx         |   4 +-
 .../src/connection/SupersetClientClass.ts          |  26 +-
 .../src/connection/callApi/parseResponse.ts        |  33 +-
 .../superset-ui-core/src/connection/index.ts       |   3 +
 .../src/connection/normalizeBackendUrls.ts         |  59 ++
 .../src/query/api/legacy/getDatasourceMetadata.ts  |   2 +-
 .../superset-ui-core/src/utils/mapStyles.test.ts   |  16 +
 .../superset-ui-core/src/utils/mapStyles.ts        |   3 +
 .../test/chart/clients/ChartClient.test.ts         |  26 +-
 .../SupersetClientAppRootContract.test.ts          | 113 +++
 .../test/connection/normalizeBackendUrls.test.ts   |  89 +++
 .../query/api/legacy/getDatasourceMetadata.test.ts |   4 +-
 .../playwright/pages/DashboardPage.ts              |   4 +-
 superset-frontend/playwright/utils/urls.ts         |   2 +-
 .../spec/helpers/sourceTreeScanner.ts              | 183 +++++
 .../spec/helpers/withApplicationRoot.ts            |  53 ++
 .../src/SqlLab/components/QueryTable/index.tsx     |   5 +-
 .../SqlLab/components/ResultSet/ResultSet.test.tsx | 123 ++++
 .../src/SqlLab/components/ResultSet/index.tsx      |  15 +-
 .../SqlLab/components/SaveDatasetModal/index.tsx   |  12 +-
 .../DatasourceEditor/DatasourceEditor.tsx          |   6 +-
 .../DashboardLinksExternal.test.tsx                |  10 +-
 .../components/DashboardLinksExternal/index.tsx    |   2 +-
 .../tests/DatasourceEditor.test.tsx                |  55 ++
 .../src/components/FacePile/index.tsx              |   2 +-
 .../src/components/ListView/CrossLinks.test.tsx    |   7 +-
 .../src/components/ListView/CrossLinks.tsx         |   2 +-
 .../StreamingExportModal/useStreamingExport.ts     |   6 +-
 superset-frontend/src/components/Tag/index.tsx     |   2 +-
 .../src/core/navigation/index.test.ts              |  12 +-
 superset-frontend/src/core/navigation/index.ts     |   6 +-
 .../src/dashboard/actions/dashboardState.test.ts   |  70 +-
 .../src/dashboard/actions/dashboardState.ts        |   6 +-
 .../src/dashboard/actions/datasources.ts           |   2 +-
 .../DashboardBuilder/DashboardBuilder.test.tsx     |   2 +-
 .../DashboardBuilder/DashboardContainer.test.tsx   |   2 +-
 .../SliceHeaderControls.subdirectory.test.tsx      | 172 +++++
 .../components/SliceHeaderControls/index.tsx       |   3 +-
 .../URLShortLinkButton/URLShortLinkButton.test.tsx |   9 +-
 .../gridComponents/Tab/Tab.subdirectory.test.tsx   | 146 ++++
 .../components/gridComponents/Tab/Tab.test.tsx     |  31 +
 .../components/gridComponents/Tab/Tab.tsx          |   5 +-
 .../FilterBar/FilterBar.subdirectory.test.ts       | 207 ++++++
 .../components/nativeFilters/FilterBar/index.tsx   |   6 +-
 .../src/dashboard/util/risonFilters.ts             |  24 +-
 .../EmbedCodeContent.subdirectory.test.tsx         |  78 ++
 .../AnnotationLayerControl/AnnotationLayer.tsx     |  10 +-
 .../explore/components/controls/ViewQuery.test.tsx |   1 +
 .../src/explore/components/controls/ViewQuery.tsx  |   9 +-
 .../DashboardsSubMenu.test.tsx                     |  31 +-
 .../DashboardsSubMenu.tsx                          |   4 +-
 .../src/explore/exploreUtils/exploreUtils.test.tsx |  22 +-
 .../src/explore/exploreUtils/exportChart.test.ts   |  17 +-
 .../src/explore/exploreUtils/getExploreUrl.test.ts |  10 +-
 .../explore/exploreUtils/getURIDirectory.test.ts   |   6 +-
 .../src/explore/exploreUtils/index.ts              |   4 +-
 .../src/extensions/ExtensionsLoader.test.ts        | 108 +++
 .../src/extensions/ExtensionsLoader.ts             |   7 +-
 .../src/features/alerts/AlertReportModal.tsx       |   2 +-
 .../AllEntitiesTable.subdirectory.test.tsx         | 152 ++++
 .../src/features/allEntities/AllEntitiesTable.tsx  |   5 +-
 .../DatabaseModal.subdirectory.test.ts             | 160 +++++
 .../DatasetPanel.subdirectory.test.tsx             | 114 +++
 .../AddDataset/DatasetPanel/DatasetPanel.tsx       |  12 +-
 .../datasets/AddDataset/LeftPanel/index.tsx        |   2 +-
 .../src/features/home/Menu.subdirectory.test.tsx   | 230 ++++++
 superset-frontend/src/features/home/Menu.test.tsx  | 184 +++++
 superset-frontend/src/features/home/Menu.tsx       |  37 +-
 .../src/features/home/RightMenu.test.tsx           |  64 ++
 superset-frontend/src/features/home/RightMenu.tsx  |  10 +-
 .../src/features/home/SavedQueries.tsx             |   4 +-
 superset-frontend/src/middleware/logger.test.ts    |   6 +-
 .../src/middleware/loggerMiddleware.ts             |  11 +-
 .../src/pages/AnnotationLayerList/index.tsx        |   6 +-
 .../src/pages/AnnotationList/index.tsx             |   3 +-
 .../pages/ChartList/ChartList.listview.test.tsx    |   4 +-
 .../src/pages/ChartList/ChartList.testHelpers.tsx  |  18 +-
 .../DashboardList/DashboardList.testHelpers.tsx    |  10 +-
 superset-frontend/src/pages/DatabaseList/index.tsx |   7 +-
 .../DatasetList/DatasetList.subdirectory.test.tsx  | 135 ++++
 superset-frontend/src/pages/DatasetList/index.tsx  |  18 +-
 .../FileHandler/FileHandler.subdirectory.test.tsx  | 267 +++++++
 .../src/pages/FileHandler/index.test.tsx           |  10 +-
 superset-frontend/src/pages/FileHandler/index.tsx  |  10 +-
 superset-frontend/src/pages/Login/Login.test.tsx   |  67 +-
 superset-frontend/src/pages/Login/index.tsx        |  11 +-
 .../pages/RedirectWarning/RedirectWarning.test.tsx |  78 ++
 .../src/pages/RedirectWarning/index.tsx            |  38 +-
 .../src/pages/RedirectWarning/utils.test.ts        |  59 ++
 .../src/pages/RedirectWarning/utils.ts             |  15 +
 superset-frontend/src/pages/Register/index.tsx     |   7 +-
 .../src/pages/SavedQueryList/index.tsx             |  13 +-
 superset-frontend/src/pages/Tags/index.tsx         |   2 +-
 superset-frontend/src/preamble.ts                  |   4 +-
 superset-frontend/src/pwa-manifest.json            |  65 --
 superset-frontend/src/utils/assetUrl.test.ts       |  39 +
 superset-frontend/src/utils/assetUrl.ts            |  17 +-
 .../src/utils/getBootstrapData.test.ts             |  73 +-
 superset-frontend/src/utils/getBootstrapData.ts    |  56 +-
 .../src/utils/navigationUtils.AppLink.test.tsx     |  95 +++
 .../src/utils/navigationUtils.appRoot.test.tsx     | 136 ++++
 .../src/utils/navigationUtils.directDom.test.tsx   | 244 +++++++
 .../src/utils/navigationUtils.invariants.test.ts   | 375 ++++++++++
 .../src/utils/navigationUtils.schemes.test.ts      | 207 ++++++
 .../src/utils/navigationUtils.test.ts              | 783 ++++++++++++++++++++-
 superset-frontend/src/utils/navigationUtils.ts     | 486 ++++++++++++-
 .../src/utils/pathUtils.parity.test.ts             | 166 +++++
 superset-frontend/src/utils/pathUtils.test.ts      | 151 ++++
 superset-frontend/src/utils/pathUtils.ts           |  44 +-
 superset-frontend/src/utils/urlUtils.test.ts       | 131 ++++
 superset-frontend/src/utils/urlUtils.ts            |  41 +-
 superset-frontend/src/views/App.tsx                |   2 +-
 superset-frontend/src/views/CRUD/hooks.ts          |   6 +-
 superset-frontend/src/views/routePaths.ts          |  12 +-
 superset-frontend/src/views/routes.test.tsx        |  49 ++
 superset-frontend/src/views/routes.tsx             |  12 +-
 superset-frontend/webpack.config.js                |   6 +-
 superset/app.py                                    |  37 +-
 superset/config.py                                 |  10 +
 superset/connectors/sqla/models.py                 |  22 +-
 superset/extensions/__init__.py                    |  11 +-
 superset/initialization/__init__.py                |   9 +-
 superset/mcp_service/dashboard/schemas.py          |   6 +-
 .../tool/add_chart_to_existing_dashboard.py        |   8 +-
 .../dashboard/tool/generate_dashboard.py           |   3 +-
 .../mcp_service/dashboard/tool/update_dashboard.py |   5 +-
 superset/middleware/__init__.py                    |  16 +
 superset/middleware/legacy_prefix_redirect.py      | 256 +++++++
 superset/models/core.py                            |   6 +-
 superset/models/dashboard.py                       |  29 +-
 superset/models/slice.py                           |  19 +-
 superset/tasks/async_queries.py                    |   2 +-
 superset/templates/superset/spa.html               |   6 +-
 superset/translations/ar/LC_MESSAGES/messages.po   |   6 +
 superset/translations/ca/LC_MESSAGES/messages.po   |   6 +
 superset/translations/cs/LC_MESSAGES/messages.po   |   6 +
 superset/translations/de/LC_MESSAGES/messages.po   |   6 +
 superset/translations/en/LC_MESSAGES/messages.po   |   6 +
 superset/translations/es/LC_MESSAGES/messages.po   |   6 +
 superset/translations/fa/LC_MESSAGES/messages.po   |   6 +
 superset/translations/fi/LC_MESSAGES/messages.po   |   6 +
 superset/translations/fr/LC_MESSAGES/messages.po   |   6 +
 superset/translations/it/LC_MESSAGES/messages.po   |   6 +
 superset/translations/ja/LC_MESSAGES/messages.po   |   6 +
 superset/translations/ko/LC_MESSAGES/messages.po   |   6 +
 superset/translations/lv/LC_MESSAGES/messages.po   |   6 +
 superset/translations/messages.pot                 |   6 +
 superset/translations/mi/LC_MESSAGES/messages.po   |   6 +
 superset/translations/nl/LC_MESSAGES/messages.po   |   6 +
 superset/translations/pl/LC_MESSAGES/messages.po   |   6 +
 superset/translations/pt/LC_MESSAGES/messages.po   |   6 +
 .../translations/pt_BR/LC_MESSAGES/messages.po     |   6 +
 superset/translations/ru/LC_MESSAGES/messages.po   |   6 +
 superset/translations/sk/LC_MESSAGES/messages.po   |   6 +
 superset/translations/sl/LC_MESSAGES/messages.po   |   6 +
 superset/translations/th/LC_MESSAGES/messages.po   |   6 +
 superset/translations/tr/LC_MESSAGES/messages.po   |   6 +
 superset/translations/uk/LC_MESSAGES/messages.po   |   6 +
 superset/translations/zh/LC_MESSAGES/messages.po   |   6 +
 .../translations/zh_TW/LC_MESSAGES/messages.po     |   6 +
 superset/utils/core.py                             |  18 +-
 superset/views/all_entities.py                     |   2 +-
 superset/views/base.py                             |   1 +
 superset/views/core.py                             |  60 +-
 superset/views/datasource/views.py                 |   6 +-
 superset/views/explore.py                          |  19 +-
 superset/views/pwa_manifest.py                     | 159 +++++
 superset/views/tags.py                             |   2 +-
 superset/views/utils.py                            | 159 ++++-
 tests/integration_tests/core_tests.py              |  55 +-
 tests/integration_tests/dashboard_tests.py         |  26 +-
 tests/integration_tests/dashboards/api_tests.py    |   6 +-
 tests/integration_tests/datasource_tests.py        |  17 +
 tests/integration_tests/event_logger_tests.py      |   6 +-
 tests/integration_tests/log_api_tests.py           |   8 +-
 .../commands/execute_dashboard_report_tests.py     |   2 +-
 tests/integration_tests/reports/commands_tests.py  |   2 +-
 tests/integration_tests/security_tests.py          |   7 +-
 tests/integration_tests/strategy_tests.py          |   2 +-
 tests/integration_tests/utils_tests.py             |   2 +-
 .../views/test_explore_redirect.py                 | 326 +++++++++
 tests/unit_tests/commands/report/execute_test.py   |  22 +-
 tests/unit_tests/initialization_test.py            | 128 +++-
 .../dashboard/test_dashboard_schemas.py            |   8 +-
 .../tool/test_add_chart_to_existing_dashboard.py   |   3 +-
 .../dashboard/tool/test_dashboard_generation.py    |  15 +-
 .../dashboard/tool/test_dashboard_tools.py         |   5 +-
 tests/unit_tests/middleware/__init__.py            |  16 +
 .../middleware/test_legacy_prefix_redirect.py      | 726 +++++++++++++++++++
 tests/unit_tests/models/dashboard_test.py          |  87 ++-
 tests/unit_tests/models/slice_test.py              |  58 ++
 tests/unit_tests/test_subdirectory_url_for.py      | 256 +++++++
 tests/unit_tests/utils/test_core.py                |  21 +
 tests/unit_tests/views/test_pwa_manifest.py        | 354 ++++++++++
 .../views/test_redirect_view_subdirectory.py       | 221 ++++++
 tests/unit_tests/views/test_spa_service_worker.py  | 182 +++++
 200 files changed, 9886 insertions(+), 659 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index ab5994402bc..8432631c244 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,24 @@ assists people when migrating to a new version.
 
 ## Next
 
+- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime be [...]
+
+- [39925](https://github.com/apache/superset/pull/39925): `SupersetClient.getUrl()` now strips a single leading application-root segment from the supplied `endpoint` before building the request URL, so a caller that accidentally pre-prefixes its endpoint (for example by wrapping it with `ensureAppRoot` before passing it to the client) no longer produces a doubled `/superset/superset/...` URL under subdirectory deployment. The strip is **single-pass** — a genuine `/superset/superset/<slug [...]
+
+- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard/<id>/`, `/superset/dashboard/p/<key>/`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard/<id>/`, `/dashboard/p/<key>/`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/supers [...]
+
+- **Breaking — Three sibling view classes route prefix removed.** Following the same rationale as the `Superset` class above, `ExplorePermalinkView` (`superset/views/explore.py`), `TagModelView`, and `TaggedObjectsModelView` (`superset/views/tags.py`, `superset/views/all_entities.py`) now mount at the application root rather than a hard-coded `/superset/...`. The user-visible URLs `/superset/explore/p/<key>/`, `/superset/tags/`, and `/superset/all_entities/` are unchanged under subdirect [...]
+
+- **Legacy `/superset/*` path support.** A new outermost WSGI middleware `LegacyPrefixRedirectMiddleware` (`superset/middleware/legacy_prefix_redirect.py`) 308-redirects every enumerated legacy `/superset/<canonical>` path to its post-`route_base=""` canonical location (e.g. `/superset/welcome/` → `/welcome/` under root; → `/superset/welcome/` under `SUPERSET_APP_ROOT=/superset`, because the canonical resolves through `AppRootMiddleware`). Bookmarks, email links, and external integration [...]
+
+- **PWA web app manifest served dynamically.** The PWA manifest is now served at `/pwa-manifest.json` (under `APPLICATION_ROOT`) by a new `PwaManifestView` (`superset/views/pwa_manifest.py`) instead of the static file at `/static/assets/pwa-manifest.json`. The legacy static source at `superset-frontend/src/pwa-manifest.json` has been removed (along with its `webpack.config.js` `CopyPlugin` rule). The new endpoint resolves `APPLICATION_ROOT` and `STATIC_ASSETS_PREFIX` at request time so P [...]
+
+- **Hard re-bookmark break — `/superset/sql/<database_id>/`.** SQL Lab moved to its own blueprint at `/sqllab/`. The legacy `/superset/sql/<id>/` shape changed to a query-string form (`/sqllab/?dbid=<id>`); no 1:1 path mapping exists, so `LegacyPrefixRedirectMiddleware` does **not** redirect this route — it passes through and surfaces a 404. Users with bookmarks to `/superset/sql/<id>/` must update them to `/sqllab/?dbid=<id>`.
+
+- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` →  [...]
+
+- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `su [...]
+
 ### SQL Lab denies large-object and information_schema access by default
 
 `DISALLOWED_SQL_FUNCTIONS` and `DISALLOWED_SQL_TABLES` now ship with additional default entries, so SQL Lab and chart-data queries that reference them are rejected where they were previously allowed:
diff --git a/superset-frontend/cypress-base/cypress/utils/urls.ts b/superset-frontend/cypress-base/cypress/utils/urls.ts
index 66110bcc64b..ce8eecaa992 100644
--- a/superset-frontend/cypress-base/cypress/utils/urls.ts
+++ b/superset-frontend/cypress-base/cypress/utils/urls.ts
@@ -19,9 +19,8 @@
 
 export const DASHBOARD_LIST = '/dashboard/list/';
 export const CHART_LIST = '/chart/list/';
-export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
-export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/';
-export const SUPPORTED_CHARTS_DASHBOARD =
-  '/superset/dashboard/supported_charts_dash/';
-export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
+export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
+export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
+export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
+export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
 export const DATABASE_LIST = '/databaseview/list';
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 488c9c65c6e..72a867ecf65 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -8439,9 +8439,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8459,9 +8456,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8479,9 +8473,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8499,9 +8490,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8519,9 +8507,6 @@
         "riscv64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8539,9 +8524,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8559,9 +8541,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -8579,9 +8558,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -10651,9 +10627,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -10670,9 +10643,6 @@
       "cpu": [
         "arm64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -10689,9 +10659,6 @@
       "cpu": [
         "ppc64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -10708,9 +10675,6 @@
       "cpu": [
         "s390x"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -10727,9 +10691,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "glibc"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -10746,9 +10707,6 @@
       "cpu": [
         "x64"
       ],
-      "libc": [
-        "musl"
-      ],
       "license": "Apache-2.0 AND MIT",
       "optional": true,
       "os": [
@@ -26487,6 +26445,21 @@
         }
       }
     },
+    "node_modules/jsdom/node_modules/@noble/hashes": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+      "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/jsdom/node_modules/css-tree": {
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -43106,6 +43079,21 @@
         }
       }
     },
+    "node_modules/whatwg-url/node_modules/@noble/hashes": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+      "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/whatwg-url/node_modules/webidl-conversions": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts b/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts
index 033c5dd9195..397a854a2a5 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/clients/ChartClient.ts
@@ -109,7 +109,7 @@ export default class ChartClient {
         (await buildQueryRegistry.get(visType)) ?? (() => formData);
       const requestConfig: RequestConfig = useLegacyApi
         ? {
-            endpoint: '/superset/explore_json/',
+            endpoint: '/explore_json/',
             postPayload: {
               form_data: buildQuery(formData),
             },
@@ -139,7 +139,7 @@ export default class ChartClient {
   ): Promise<Datasource> {
     return this.client
       .get({
-        endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
+        endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
         ...options,
       } as RequestConfig)
       .then(response => response.json as Datasource);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
index 774bad741c4..baae1ae2ce4 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
@@ -263,9 +263,7 @@ export default function StatefulChart(props: StatefulChartProps) {
       if (!useLegacyApi && !queryContext.queries) {
         queryContext = { queries: [queryContext] };
       }
-      const endpoint = useLegacyApi
-        ? '/superset/explore_json/'
-        : '/api/v1/chart/data';
+      const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
 
       const requestConfig: RequestConfig = {
         endpoint,
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
index 5bd2d3d64bf..dbc41065e48 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
@@ -82,7 +82,11 @@ export default class SupersetClientClass {
     unauthorizedHandler = undefined,
   }: ClientConfig = {}) {
     const url = new URL(`${protocol || 'https:'}//${host || 'localhost'}`);
-    this.appRoot = appRoot;
+    // Strip a trailing slash so the getUrl dedupe comparisons and the final
+    // `${this.appRoot}/${...}` build stay correct regardless of how the root
+    // was supplied. Mirrors normalizeBackendUrlString / AppRootMiddleware /
+    // LegacyPrefixRedirectMiddleware, which all rstrip the root.
+    this.appRoot = appRoot.replace(/\/$/, '');
     this.host = url.host;
     this.protocol = url.protocol as Protocol;
     this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json
@@ -296,8 +300,26 @@ export default class SupersetClientClass {
     const host = inputHost ?? this.host;
     const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash
 
+    // Strip a single leading appRoot segment so callers that accidentally
+    // pre-prefix their endpoint (e.g. by wrapping with ensureAppRoot before
+    // passing to the client) do not produce a doubled `/superset/superset/...`
+    // URL. Single-pass strip mirrors
+    // `stripAppRoot` in `src/utils/pathUtils` and `normalizeBackendUrlString`
+    // exactly: a genuine `/superset/superset/<slug>` is a legitimate route, not
+    // a double-prefix bug. The L2 static invariant still flags pre-prefixing as
+    // a migration issue; this is the runtime safety net.
+    let cleanEndpoint = endpoint;
+    const root = this.appRoot;
+    if (root) {
+      if (cleanEndpoint === root) {
+        cleanEndpoint = '';
+      } else if (cleanEndpoint.startsWith(`${root}/`)) {
+        cleanEndpoint = cleanEndpoint.slice(root.length);
+      }
+    }
+
     return `${this.protocol}//${cleanHost}${this.appRoot}/${
-      endpoint[0] === '/' ? endpoint.slice(1) : endpoint
+      cleanEndpoint[0] === '/' ? cleanEndpoint.slice(1) : cleanEndpoint
     }`;
   }
 }
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts b/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts
index 0716f16a8ac..38c76b60ffd 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/callApi/parseResponse.ts
@@ -55,24 +55,25 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
   if (parseMethod === 'json-bigint') {
     const rawData = await response.text();
     const json = JSONbig.parse(rawData);
+    const decoded = cloneDeepWith(json, (value: any) => {
+      if (
+        value?.isInteger?.() === true &&
+        (value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
+          value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
+      ) {
+        // toFixed() avoids scientific notation, which BigInt() rejects.
+        return BigInt(value.toFixed());
+      }
+      // // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
+      // // TODO: clean up after json-bigint>1.0.1 is released
+      if (value?.isNaN?.() === false) {
+        return value?.toNumber?.();
+      }
+      return undefined;
+    });
     const result: JsonResponse = {
       response,
-      json: cloneDeepWith(json, (value: any) => {
-        if (
-          value?.isInteger?.() === true &&
-          (value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
-            value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
-        ) {
-          // toFixed() avoids scientific notation, which BigInt() rejects.
-          return BigInt(value.toFixed());
-        }
-        // // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
-        // // TODO: clean up after json-bigint>1.0.1 is released
-        if (value?.isNaN?.() === false) {
-          return value?.toNumber?.();
-        }
-        return undefined;
-      }),
+      json: decoded,
     };
     return result as ReturnType;
   }
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/index.ts b/superset-frontend/packages/superset-ui-core/src/connection/index.ts
index 3ac9806a055..4fdf85e3f9b 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/index.ts
@@ -21,6 +21,9 @@ export { default as callApi } from './callApi';
 export { default as SupersetClient } from './SupersetClient';
 export { default as SupersetClientClass } from './SupersetClientClass';
 
+export { normalizeBackendUrlString } from './normalizeBackendUrls';
+export type { NormalizeOptions } from './normalizeBackendUrls';
+
 export * from './types';
 export * from './constants';
 export { default as __hack_reexport_connection } from './types';
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts b/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts
new file mode 100644
index 00000000000..31f2731e7fc
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/connection/normalizeBackendUrls.ts
@@ -0,0 +1,59 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Strips the configured application root from a single backend-supplied URL
+ * string so the frontend speaks router-relative paths. Apply it at the few call
+ * sites that surface a router-relative URL from an API response (e.g. a
+ * dataset's `explore_url`) before handing the value to a consumer that
+ * re-prefixes the root — `SupersetClient.getUrl`, `makeUrl`, or a react-router
+ * `<Link>` resolving against the Router `basename`. Without it those consumers
+ * would re-prefix an already-rooted path into `/superset/superset/...`.
+ *
+ * Absolute (`https:`, `ftp:`, `mailto:`, `tel:`) and protocol-relative (`//`)
+ * URLs pass through untouched, so an operator-configured external
+ * `default_endpoint` on a dataset is left alone.
+ */
+
+export interface NormalizeOptions {
+  /** Application root to strip. Empty string disables normalisation. */
+  applicationRoot: string;
+}
+
+const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;
+
+function stripTrailingSlash(root: string): string {
+  return root.endsWith('/') ? root.slice(0, -1) : root;
+}
+
+/** Normalise a single router-relative URL string. */
+export function normalizeBackendUrlString(
+  value: string,
+  options: NormalizeOptions,
+): string {
+  const root = stripTrailingSlash(options.applicationRoot);
+  if (!root) return value;
+  if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
+  if (value.startsWith('//')) return value;
+  if (value === root) return '/';
+  if (value.startsWith(`${root}/`)) {
+    return value.slice(root.length);
+  }
+  return value;
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts b/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts
index 344d5891cae..c4a5c0f26fb 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/api/legacy/getDatasourceMetadata.ts
@@ -32,7 +32,7 @@ export default function getDatasourceMetadata({
 }: Params) {
   return client
     .get({
-      endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
+      endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
       ...requestConfig,
     })
     .then(response => response.json as Datasource);
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts
index 5313a9c1c2e..170c04370ff 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.test.ts
@@ -283,6 +283,22 @@ test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
   }
 });
 
+test('relative raster tile templates do not receive OSM attribution', () => {
+  // A host-relative template cannot be parsed by `new URL`, so the OSM
+  // hostname check must fall through to "not OSM" rather than throw.
+  const relativeTileUrl = '/local-tiles/{z}/{x}/{y}.png';
+  const style = resolveMapStyle(
+    `tile://${relativeTileUrl}`,
+    'default-style.json',
+  );
+
+  expect(typeof style).toBe('object');
+  if (typeof style !== 'string') {
+    expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
+    expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
+  }
+});
+
 test('style JSON URLs pass through without raster wrapping', () => {
   const styleUrl = 'https://example.com/styles/custom-style.json';
 
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts
index 56c4ca1714c..901cb00a9fd 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/mapStyles.ts
@@ -88,6 +88,9 @@ type BootstrapData = {
 };
 
 export function getBootstrapDataFromDocument(): unknown {
+  /* istanbul ignore if -- a missing document only occurs in SSR/worker
+     contexts, which Jest cannot simulate: jsdom pins `document` as a
+     non-configurable global */
   if (typeof document === 'undefined') {
     return undefined;
   }
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts
index 5bc0b6a9f1a..8d11b98d175 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts
@@ -176,7 +176,9 @@ describe('ChartClient', () => {
         Promise.reject(new Error('Unexpected all to v1 API')),
       );
 
-      fetchMock.post('glob:*/superset/explore_json/', {
+      // post `Superset.route_base = ""`, the legacy endpoint
+      // collapsed from `/superset/explore_json/` to `/explore_json/`.
+      fetchMock.post('glob:*/explore_json/', {
         field1: 'abc',
         field2: 'def',
       });
@@ -198,13 +200,10 @@ describe('ChartClient', () => {
 
   describe('.loadDatasource(datasourceKey, options)', () => {
     test('fetches datasource', () => {
-      fetchMock.get(
-        'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
-        {
-          field1: 'abc',
-          field2: 'def',
-        },
-      );
+      fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
+        field1: 'abc',
+        field2: 'def',
+      });
 
       return expect(chartClient.loadDatasource('1__table')).resolves.toEqual({
         field1: 'abc',
@@ -264,13 +263,10 @@ describe('ChartClient', () => {
         color: 'living-coral',
       });
 
-      fetchMock.get(
-        'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
-        {
-          name: 'transactions',
-          schema: 'staging',
-        },
-      );
+      fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
+        name: 'transactions',
+        schema: 'staging',
+      });
 
       fetchMock.post('glob:*/api/v1/chart/data', {
         lorem: 'ipsum',
diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts
new file mode 100644
index 00000000000..ff8cf65863a
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientAppRootContract.test.ts
@@ -0,0 +1,113 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SupersetClientClass } from '@superset-ui/core';
+
+// SupersetClient is expected to apply the configured appRoot exactly once.
+// Callers must pass router-relative endpoints; pre-prefixing causes the
+// double-prefix bug documented below.
+
+describe('SupersetClient applies the application root exactly once', () => {
+  const buildClient = () =>
+    new SupersetClientClass({
+      protocol: 'https:',
+      host: 'config_host',
+      appRoot: '/superset',
+    });
+
+  test('endpoint without leading slash is concatenated correctly', () => {
+    expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe(
+      'https://config_host/superset/api/v1/chart',
+    );
+  });
+
+  test('endpoint with leading slash is normalised to a single root segment', () => {
+    expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe(
+      'https://config_host/superset/api/v1/chart',
+    );
+  });
+
+  // A trailing slash on the configured appRoot is stripped at construction
+  // (SupersetClientClass `appRoot.replace(/\/$/, '')`). Without it, a root of
+  // '/superset/' produced 'https://host/superset//foo', and the dedupe block's
+  // `startsWith('/superset//')` check silently failed to dedupe a pre-prefixed
+  // endpoint. This pins both behaviours against regression.
+  test('trailing-slash appRoot is normalised to a single root segment', () => {
+    const client = new SupersetClientClass({
+      protocol: 'https:',
+      host: 'config_host',
+      appRoot: '/superset/',
+    });
+    expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
+      'https://config_host/superset/api/v1/chart',
+    );
+    // and a pre-prefixed endpoint is still deduped, not doubled
+    expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
+      'https://config_host/superset/api/v1/chart',
+    );
+  });
+
+  // Runtime safety net: if a caller pre-prefixes the endpoint (e.g. by wrapping
+  // with ensureAppRoot before calling), getUrl strips the duplicate. The L2
+  // static invariant still flags the pattern at the call site — this guards
+  // against the bug reaching production if the static check is bypassed.
+  test('dedupes a leading application-root segment from a pre-prefixed endpoint', () => {
+    expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
+      'https://config_host/superset/api/v1/chart',
+    );
+  });
+
+  // Single-pass strip preserves a legitimate `/superset/superset/<slug>`
+  // route. Backend-supplied router-relative URLs are stripped of the root at
+  // the call sites that surface them (via `normalizeBackendUrlString`) before
+  // any re-prefixing helper sees them, so a doubled leading segment reaching
+  // `getUrl` is a real route, not a double-prefix bug. This pin guards against
+  // silent regression to a greedy strip.
+  test('strips exactly one application-root segment (single-pass)', () => {
+    expect(
+      buildClient().getUrl({ endpoint: '/superset/superset/api/v1/chart' }),
+    ).toBe('https://config_host/superset/superset/api/v1/chart');
+    expect(
+      buildClient().getUrl({
+        endpoint: '/superset/superset/superset/api/v1/chart',
+      }),
+    ).toBe('https://config_host/superset/superset/superset/api/v1/chart');
+  });
+
+  test('dedupe is segment-boundary aware — `/supersetfoo` is not a prefix match', () => {
+    expect(buildClient().getUrl({ endpoint: '/supersetfoo/x' })).toBe(
+      'https://config_host/superset/supersetfoo/x',
+    );
+  });
+
+  test('dedupes the bare application root to an empty endpoint', () => {
+    expect(buildClient().getUrl({ endpoint: '/superset' })).toBe(
+      'https://config_host/superset/',
+    );
+  });
+
+  test('empty application root produces no prefix segment', () => {
+    const client = new SupersetClientClass({
+      protocol: 'https:',
+      host: 'config_host',
+    });
+    expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
+      'https://config_host/api/v1/chart',
+    );
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts
new file mode 100644
index 00000000000..2100cbdedd7
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/test/connection/normalizeBackendUrls.test.ts
@@ -0,0 +1,89 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { normalizeBackendUrlString } from '../../src/connection/normalizeBackendUrls';
+
+const PREFIX = '/superset';
+
+describe('normalizeBackendUrlString', () => {
+  test('strips application root from a router-relative path', () => {
+    expect(
+      normalizeBackendUrlString('/superset/explore/?slice_id=1', {
+        applicationRoot: PREFIX,
+      }),
+    ).toBe('/explore/?slice_id=1');
+  });
+
+  test('strips a value that equals the application root exactly', () => {
+    expect(
+      normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }),
+    ).toBe('/');
+  });
+
+  test('tolerates a trailing slash on applicationRoot', () => {
+    expect(
+      normalizeBackendUrlString('/superset/foo', {
+        applicationRoot: '/superset/',
+      }),
+    ).toBe('/foo');
+  });
+
+  // The negative cases below prove the helper is conservative: it doesn't
+  // mutate external URLs or path segments that merely share text with the root.
+  test('passes absolute URLs through unchanged', () => {
+    expect(
+      normalizeBackendUrlString('https://external.example.com/superset/foo', {
+        applicationRoot: PREFIX,
+      }),
+    ).toBe('https://external.example.com/superset/foo');
+  });
+
+  test('passes protocol-relative URLs through unchanged', () => {
+    expect(
+      normalizeBackendUrlString('//cdn.example.com/superset/foo', {
+        applicationRoot: PREFIX,
+      }),
+    ).toBe('//cdn.example.com/superset/foo');
+  });
+
+  test('does not strip a similar-but-different prefix segment', () => {
+    // /superset-public/... shares text with /superset but is a different path
+    // segment. Only /superset followed by / or end-of-string counts.
+    expect(
+      normalizeBackendUrlString('/superset-public/explore/?slice_id=1', {
+        applicationRoot: PREFIX,
+      }),
+    ).toBe('/superset-public/explore/?slice_id=1');
+  });
+
+  test('is a no-op when application root is empty', () => {
+    expect(
+      normalizeBackendUrlString('/superset/explore/?slice_id=1', {
+        applicationRoot: '',
+      }),
+    ).toBe('/superset/explore/?slice_id=1');
+  });
+
+  test('is idempotent: normalize(normalize(x)) === normalize(x)', () => {
+    const once = normalizeBackendUrlString('/superset/explore/?id=1', {
+      applicationRoot: PREFIX,
+    });
+    const twice = normalizeBackendUrlString(once, { applicationRoot: PREFIX });
+    expect(twice).toBe(once);
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts
index be94bfa9f68..5ae788d10ad 100644
--- a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts
@@ -35,8 +35,10 @@ describe('getFormData()', () => {
       field2: 'def',
     };
 
+    // post-`route_base=""`, the legacy endpoint collapsed
+    // from `/superset/fetch_datasource_metadata` to `/fetch_datasource_metadata`.
     fetchMock.get(
-      'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
+      'glob:*/fetch_datasource_metadata?datasourceKey=1__table',
       mockData,
     );
 
diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts
index a61ff3415c1..9b81d493d94 100644
--- a/superset-frontend/playwright/pages/DashboardPage.ts
+++ b/superset-frontend/playwright/pages/DashboardPage.ts
@@ -44,7 +44,7 @@ export class DashboardPage {
    * @param slug - The dashboard slug (e.g., 'world_health')
    */
   async gotoBySlug(slug: string): Promise<void> {
-    await gotoWithRetry(this.page, `superset/dashboard/${slug}/`);
+    await gotoWithRetry(this.page, `dashboard/${slug}/`);
   }
 
   /**
@@ -52,7 +52,7 @@ export class DashboardPage {
    * @param id - The dashboard ID
    */
   async gotoById(id: number): Promise<void> {
-    await gotoWithRetry(this.page, `superset/dashboard/${id}/`);
+    await gotoWithRetry(this.page, `dashboard/${id}/`);
   }
 
   /**
diff --git a/superset-frontend/playwright/utils/urls.ts b/superset-frontend/playwright/utils/urls.ts
index ed78c0b1c53..e35eddd2249 100644
--- a/superset-frontend/playwright/utils/urls.ts
+++ b/superset-frontend/playwright/utils/urls.ts
@@ -35,5 +35,5 @@ export const URL = {
   LOGIN: 'login/',
   SAVED_QUERIES_LIST: 'savedqueryview/list/',
   SQLLAB: 'sqllab',
-  WELCOME: 'superset/welcome/',
+  WELCOME: 'welcome/',
 } as const;
diff --git a/superset-frontend/spec/helpers/sourceTreeScanner.ts b/superset-frontend/spec/helpers/sourceTreeScanner.ts
new file mode 100644
index 00000000000..c5798592d0c
--- /dev/null
+++ b/superset-frontend/spec/helpers/sourceTreeScanner.ts
@@ -0,0 +1,183 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { readdirSync, readFileSync, statSync } from 'fs';
+import { join, relative, resolve, sep } from 'path';
+
+const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src'];
+
+const ALWAYS_SKIP_SEGMENTS = new Set([
+  'node_modules',
+  'dist',
+  'build',
+  'coverage',
+  '__mocks__',
+  'cypress-base',
+  'playwright',
+]);
+
+const ALWAYS_SKIP_SUFFIXES = [
+  '.test.ts',
+  '.test.tsx',
+  '.stories.ts',
+  '.stories.tsx',
+];
+
+const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
+
+export interface ScanOptions {
+  /** Workspace-relative directories to scan. Defaults to the source tree. */
+  roots?: string[];
+  /** Extra path segments to skip on top of {@link ALWAYS_SKIP_SEGMENTS}. */
+  ignoreSegments?: string[];
+  /** Regex run against each line of each file. */
+  pattern: RegExp;
+  /** Workspace-relative paths (forward slashes) exempt from this scan. */
+  allowlist?: string[];
+}
+
+export interface ScanHit {
+  /** Workspace-relative path with forward slashes. */
+  file: string;
+  /** 1-based line number. */
+  line: number;
+  /** The text of the matching line, trimmed. */
+  text: string;
+  /** The substring captured by `pattern`. */
+  match: string;
+}
+
+// __dirname resolves to <workspace>/spec/helpers regardless of cwd.
+const WORKSPACE_ROOT = resolve(__dirname, '..', '..');
+
+function isSourceFile(name: string): boolean {
+  return (
+    SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) &&
+    !ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix))
+  );
+}
+
+function walk(directory: string, ignoreSegments: Set<string>): string[] {
+  const found: string[] = [];
+
+  let entries;
+  try {
+    entries = readdirSync(directory, { withFileTypes: true });
+  } catch {
+    return found;
+  }
+
+  for (const entry of entries) {
+    if (ignoreSegments.has(entry.name)) continue;
+    const absolute = join(directory, entry.name);
+
+    if (entry.isDirectory()) {
+      found.push(...walk(absolute, ignoreSegments));
+    } else if (entry.isFile() && isSourceFile(entry.name)) {
+      found.push(absolute);
+    }
+  }
+
+  return found;
+}
+
+function toForwardSlashes(path: string): string {
+  return sep === '/' ? path : path.split(sep).join('/');
+}
+
+/**
+ * Line-by-line regex scan over the source tree. Returns one {@link ScanHit}
+ * per matching line. Textual (not AST-based) — false positives on string
+ * literals should be fixed by tightening the regex.
+ */
+export function scanSource(options: ScanOptions): ScanHit[] {
+  const {
+    roots = DEFAULT_ROOTS,
+    ignoreSegments = [],
+    pattern,
+    allowlist = [],
+  } = options;
+
+  const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]);
+  const allowSet = new Set(allowlist);
+  const hits: ScanHit[] = [];
+
+  const seen = new Set<string>();
+  for (const root of roots) {
+    const absoluteRoot = resolve(WORKSPACE_ROOT, root);
+    let stat;
+    try {
+      stat = statSync(absoluteRoot);
+    } catch {
+      continue;
+    }
+    if (!stat.isDirectory()) continue;
+
+    for (const absoluteFile of walk(absoluteRoot, ignoreSet)) {
+      if (seen.has(absoluteFile)) continue;
+      seen.add(absoluteFile);
+
+      const relativePath = toForwardSlashes(
+        relative(WORKSPACE_ROOT, absoluteFile),
+      );
+      if (allowSet.has(relativePath)) continue;
+
+      const contents = readFileSync(absoluteFile, 'utf8');
+      const lines = contents.split('\n');
+
+      // Reuse the regex per file. Without the `g` flag, `.exec` ignores
+      // lastIndex, so recompiling per-line was wasted allocation.
+      const lineRegex = pattern.flags.includes('g')
+        ? new RegExp(pattern.source, pattern.flags.replace('g', ''))
+        : pattern;
+
+      for (let index = 0; index < lines.length; index += 1) {
+        const lineText = lines[index];
+        const match = lineRegex.exec(lineText);
+        if (match) {
+          hits.push({
+            file: relativePath,
+            line: index + 1,
+            text: lineText.trim(),
+            match: match[0],
+          });
+        }
+      }
+    }
+  }
+
+  return hits;
+}
+
+/** Format hits as a multi-line failure message: `  file:line — text`. */
+export function formatHits(hits: ScanHit[], header: string): string {
+  if (hits.length === 0) return header;
+  const lines = hits
+    .slice(0, 50)
+    .map(hit => `  ${hit.file}:${hit.line} — ${hit.text}`);
+  const overflow =
+    hits.length > 50 ? `\n  ... and ${hits.length - 50} more` : '';
+  return `${header}\n${lines.join('\n')}${overflow}`;
+}
+
+/** Throw with a formatted message if `hits` is non-empty. */
+export function expectNoHits(hits: ScanHit[], header: string): void {
+  if (hits.length > 0) {
+    throw new Error(formatHits(hits, header));
+  }
+}
diff --git a/superset-frontend/spec/helpers/withApplicationRoot.ts b/superset-frontend/spec/helpers/withApplicationRoot.ts
new file mode 100644
index 00000000000..a8453572356
--- /dev/null
+++ b/superset-frontend/spec/helpers/withApplicationRoot.ts
@@ -0,0 +1,53 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Run `callback` with `getBootstrapData().common.application_root` set to
+ * `applicationRoot`. Resets modules so any imports inside the callback see
+ * the configured value, then restores the prior DOM and module cache on exit.
+ * Pass `''` to simulate the default root-of-domain deployment.
+ */
+export async function withApplicationRoot<T>(
+  applicationRoot: string,
+  callback: () => Promise<T> | T,
+): Promise<T> {
+  const previousBody = document.body.innerHTML;
+
+  try {
+    const bootstrapData = { common: { application_root: applicationRoot } };
+    document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
+    jest.resetModules();
+    await import('src/utils/getBootstrapData');
+    return await callback();
+  } finally {
+    document.body.innerHTML = previousBody;
+    jest.resetModules();
+  }
+}
+
+/** Run `body` once per scenario, each under a different application root. */
+export async function applicationRootScenarios<S extends { root: string }>(
+  scenarios: S[],
+  body: (scenario: S) => Promise<void> | void,
+): Promise<void> {
+  for (const scenario of scenarios) {
+    // eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state.
+    await withApplicationRoot(scenario.root, () => body(scenario));
+  }
+}
diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
index 18a3d73235b..fda383430d4 100644
--- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
@@ -44,7 +44,7 @@ import {
 import { fDuration, extendedDayjs } from '@superset-ui/core/utils/dates';
 import { SqlLabRootState } from 'src/SqlLab/types';
 import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
-import { makeUrl } from 'src/utils/pathUtils';
+import { openInNewTab } from 'src/utils/navigationUtils';
 import ResultSet from '../ResultSet';
 import HighlightedSql from '../HighlightedSql';
 import { StaticPosition, StyledTooltip, ModalResultSetWrapper } from './styles';
@@ -80,8 +80,7 @@ interface QueryTableProps {
 }
 
 const openQuery = (id: number) => {
-  const url = makeUrl(`/sqllab?queryId=${id}`);
-  window.open(url);
+  openInNewTab(`/sqllab?queryId=${id}`);
 };
 
 const QueryTable = ({
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 3885f195532..9e31eb5ab58 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -53,7 +53,28 @@ jest.mock('@superset-ui/core', () => ({
   isFeatureEnabled: jest.fn().mockReturnValue(false),
 }));
 
+// Mock openInNewTab so the Create-chart "new window" branch can be asserted
+// without spawning a real window. The rest of navigationUtils stays real so
+// existing CSV-download tests keep using the genuine `redirect`/`makeUrl`.
+jest.mock('src/utils/navigationUtils', () => ({
+  ...jest.requireActual('src/utils/navigationUtils'),
+  openInNewTab: jest.fn(),
+}));
+// eslint-disable-next-line import/order, import/first
+import { openInNewTab } from 'src/utils/navigationUtils';
+
+// Stub postFormData so the Create-chart click resolves quickly; this lets
+// the test focus on the URL composition that happens after the resolve.
+jest.mock('src/explore/exploreUtils/formData', () => ({
+  ...jest.requireActual('src/explore/exploreUtils/formData'),
+  postFormData: jest.fn(),
+}));
+// eslint-disable-next-line import/order, import/first
+import { postFormData } from 'src/explore/exploreUtils/formData';
+
 const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
+const mockOpenInNewTab = openInNewTab as jest.Mock;
+const mockPostFormData = postFormData as jest.Mock;
 
 jest.mock('src/components/ErrorMessage', () => ({
   ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
@@ -160,6 +181,9 @@ describe('ResultSet', () => {
   beforeEach(() => {
     applicationRootMock.mockReturnValue('');
     mockStartExport.mockClear();
+    mockOpenInNewTab.mockClear();
+    mockPostFormData.mockReset();
+    mockPostFormData.mockResolvedValue('test-form-data-key');
   });
 
   // Add cleanup after each test
@@ -1009,4 +1033,103 @@ describe('ResultSet', () => {
       screen.getByRole('button', { name: 'Results Action' }),
     ).toBeInTheDocument();
   });
+
+  test('Create chart in new window opens single-prefixed explore URL under subdirectory deployment', async () => {
+    // When the user metaKey-clicks "Create chart", the SQL-Lab result handoff
+    // composes an explore URL via mountExploreUrl(..., includeAppRoot=true).
+    // Under SUPERSET_APP_ROOT=/superset, the resulting URL must contain the
+    // prefix exactly once. A doubled prefix (/superset/superset/explore/…)
+    // produces a blank Explore page.
+    const appRoot = '/superset';
+    applicationRootMock.mockReturnValue(appRoot);
+
+    const queryWithId = {
+      ...queries[0],
+      results: {
+        ...queries[0].results,
+        query_id: 42,
+      },
+    };
+
+    const { getByTestId } = setup(
+      {
+        ...mockedProps,
+        queryId: queryWithId.id,
+        database: { allows_subquery: true, allows_virtual_table_explore: true },
+      },
+      mockStore({
+        ...initialState,
+        user,
+        sqlLab: {
+          ...initialState.sqlLab,
+          queries: {
+            [queryWithId.id]: queryWithId,
+          },
+        },
+      }),
+    );
+
+    const exploreButton = await waitFor(() =>
+      getByTestId('explore-results-button'),
+    );
+    fireEvent.click(exploreButton, { metaKey: true });
+
+    await waitFor(() => {
+      expect(mockOpenInNewTab).toHaveBeenCalledTimes(1);
+    });
+    const url = mockOpenInNewTab.mock.calls[0][0] as string;
+    expect(url).toMatch(/^\/superset\/explore\/\?.*form_data_key=/);
+    expect(url).not.toMatch(/\/superset\/superset\//);
+  });
+
+  test('Create chart in same window pushes router-relative explore URL under subdirectory deployment', async () => {
+    // Same-tab click (no metaKey) goes through history.push under the SPA
+    // basename Router, so mountExploreUrl is called with includeAppRoot=false.
+    // The composed URL must NOT carry an app-root prefix — the router applies
+    // it once via <Router basename={applicationRoot()}>. A premature prefix
+    // here would compound with the basename and yield /superset/superset/…
+    const appRoot = '/superset';
+    applicationRootMock.mockReturnValue(appRoot);
+
+    const queryWithId = {
+      ...queries[0],
+      results: {
+        ...queries[0].results,
+        query_id: 99,
+      },
+    };
+
+    const store = mockStore({
+      ...initialState,
+      user,
+      sqlLab: {
+        ...initialState.sqlLab,
+        queries: {
+          [queryWithId.id]: queryWithId,
+        },
+      },
+    });
+
+    const { getByTestId } = render(
+      <ResultSet
+        {...mockedProps}
+        queryId={queryWithId.id}
+        database={{
+          allows_subquery: true,
+          allows_virtual_table_explore: true,
+        }}
+      />,
+      { useRedux: true, store, useRouter: true },
+    );
+
+    const exploreButton = await waitFor(() =>
+      getByTestId('explore-results-button'),
+    );
+    fireEvent.click(exploreButton);
+
+    await waitFor(() => {
+      expect(mockPostFormData).toHaveBeenCalledTimes(1);
+    });
+    expect(mockOpenInNewTab).not.toHaveBeenCalled();
+  });
 });
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index 732d1dec8f9..cb6ee6883ac 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -16,7 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeUrl } from '@braintree/sanitize-url';
 import {
   useCallback,
   useEffect,
@@ -88,7 +87,7 @@ import { usePermissions } from 'src/hooks/usePermissions';
 import { StreamingExportModal } from 'src/components/StreamingExportModal';
 import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
 import { useConfirmModal } from 'src/hooks/useConfirmModal';
-import { makeUrl } from 'src/utils/pathUtils';
+import { makeUrl, openInNewTab, redirect } from 'src/utils/navigationUtils';
 import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
 import ExploreResultsButton from '../ExploreResultsButton';
 import HighlightedSql from '../HighlightedSql';
@@ -312,7 +311,9 @@ const ResultSet = ({
         includeAppRoot,
       );
       if (openInNewWindow) {
-        window.open(url, '_blank', 'noreferrer');
+        // `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
+        // helper re-applies `ensureAppRoot` idempotently.
+        openInNewTab(url);
       } else {
         history.push(url);
       }
@@ -379,7 +380,13 @@ const ResultSet = ({
               { rows: rowsCount.toLocaleString() },
             ),
             onConfirm: () => {
-              window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
+              // `getExportCsvUrl` already runs the path through `makeUrl`;
+              // `redirect` re-applies `ensureAppRoot` idempotently and routes
+              // the sink through navigationUtils' barriers (scheme allowlist,
+              // userinfo rejection, backslash rejection), which is a
+              // strict superset of what `sanitizeUrl` from master PR #40546
+              // provides.
+              redirect(getExportCsvUrl(query.id));
             },
             confirmText: t('OK'),
             cancelText: t('Close'),
diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
index e1de7b46634..b39ec14abcb 100644
--- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
@@ -16,7 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeUrl } from '@braintree/sanitize-url';
 import { useCallback, useState, FormEvent } from 'react';
 import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
 import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
@@ -58,6 +57,7 @@ import { postFormData } from 'src/explore/exploreUtils/formData';
 import { URL_PARAMS } from 'src/constants';
 import { isEmpty } from 'lodash-es';
 import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
+import { openInNewTab, redirect } from 'src/utils/navigationUtils';
 
 interface QueryDatabase {
   id?: number;
@@ -244,10 +244,16 @@ export const SaveDatasetModal = ({
     useState(false);
 
   const createWindow = (url: string) => {
+    // `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
+    // navigationUtils helpers re-apply `ensureAppRoot` idempotently.
     if (openWindow) {
-      window.open(sanitizeUrl(url), '_blank', 'noreferrer');
+      // `openInNewTab` / `redirect` route the sink through navigationUtils'
+      // barriers (scheme allowlist, userinfo rejection, backslash
+      // rejection) — strictly stronger than master PR #40546's `sanitizeUrl`
+      // wrap, which only rejects `javascript:` / `data:` / `vbscript:`.
+      openInNewTab(url);
     } else {
-      window.location.href = sanitizeUrl(url);
+      redirect(url);
     }
   };
   const formDataWithDefaults = {
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index f3cf210629b..3ca1a671754 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -84,7 +84,7 @@ import {
 } from 'src/database/actions';
 import Mousetrap from 'mousetrap';
 import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
-import { makeUrl } from 'src/utils/pathUtils';
+import { makeUrl, openInNewTab } from 'src/utils/navigationUtils';
 import {
   OwnerSelectLabel,
   OWNER_TEXT_LABEL_PROP,
@@ -1181,7 +1181,9 @@ function DatasourceEditor({
   }, [datasource]);
 
   const openOnSqlLab = useCallback(() => {
-    window.open(getSQLLabUrl(), '_blank', 'noopener,noreferrer');
+    // `getSQLLabUrl()` already runs the path through `makeUrl`; `openInNewTab`
+    // re-applies `ensureAppRoot`, which is idempotent on already-prefixed paths.
+    openInNewTab(getSQLLabUrl());
   }, [getSQLLabUrl]);
 
   const onQueryRun = useCallback(async () => {
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx
index 28cac0d17a1..853e8e7d5f5 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/DashboardLinksExternal.test.tsx
@@ -66,7 +66,7 @@ test('renders single dashboard link correctly', () => {
 
   const link = screen.getByText('Sales Dashboard');
   expect(link).toBeInTheDocument();
-  expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
+  expect(link.closest('a')).toHaveAttribute('href', '/dashboard/1/');
   expect(link.closest('a')).toHaveAttribute('target', '_blank');
 });
 
@@ -98,9 +98,9 @@ test('links have correct href attributes', () => {
     .getByText(', Very Long Dashboard Name That Should Be Truncated')
     .closest('a');
 
-  expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
-  expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
-  expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
+  expect(salesLink).toHaveAttribute('href', '/dashboard/1/');
+  expect(analyticsLink).toHaveAttribute('href', '/dashboard/2/');
+  expect(longNameLink).toHaveAttribute('href', '/dashboard/3/');
 });
 
 test('applies correct styling classes', () => {
@@ -124,5 +124,5 @@ test('handles dashboard with empty title', () => {
 
   const link = screen.getByRole('link');
   expect(link).toHaveTextContent('');
-  expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
+  expect(link).toHaveAttribute('href', '/dashboard/1/');
 });
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx
index d9b25673dae..0ac14ebbffc 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DashboardLinksExternal/index.tsx
@@ -62,7 +62,7 @@ const DashboardLinksExternal = ({
         {dashboards.map((dashboard, index) => (
           <GenericLink
             key={dashboard.id}
-            to={`/superset/dashboard/${dashboard.id}/`}
+            to={`/dashboard/${dashboard.id}/`}
             target="_blank"
           >
             {index === 0
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
index 97e1e5f5dfb..e4fd777d19a 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
@@ -25,6 +25,7 @@ import {
   within,
 } from 'spec/helpers/testing-library';
 import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
+import * as getBootstrapData from 'src/utils/getBootstrapData';
 import {
   createProps,
   DATASOURCE_ENDPOINT,
@@ -822,3 +823,57 @@ test('calculated column search is case-insensitive', async () => {
     expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
   });
 });
+
+test('Open in SQL lab href is single-prefixed under subdirectory deployment', () => {
+  // The Open-in-SQL-Lab link's href is produced by `getSQLLabUrl()`:
+  //   return makeUrl(`/sqllab/?${queryParams.toString()}`);
+  // `makeUrl` is the idempotent app-root prefix helper from
+  // `src/utils/navigationUtils`. Rendering the link requires both the
+  // virtual datasourceType state AND a populated Redux `database.queryResult`
+  // slice (which is not part of the default test reducer tree). Calling
+  // `makeUrl` directly with a `/superset` mock exercises the exact path the
+  // component takes and pins the dedupe invariant for the underlying helper.
+  const applicationRootSpy = jest
+    .spyOn(getBootstrapData, 'applicationRoot')
+    .mockReturnValue('/superset');
+  try {
+    const { makeUrl } = jest.requireActual('src/utils/navigationUtils');
+    const queryParams = new URLSearchParams({
+      dbid: '1',
+      sql: 'SELECT * FROM users',
+      name: 'Vehicle Sales',
+      schema: 'public',
+      autorun: 'true',
+      isDataset: 'true',
+    });
+    const url = makeUrl(`/sqllab/?${queryParams.toString()}`);
+    expect(url).toMatch(/^\/superset\/sqllab\/\?/);
+    expect(url).not.toMatch(/\/superset\/superset\//);
+  } finally {
+    applicationRootSpy.mockRestore();
+  }
+});
+
+test('DatasourceEditor source pins getSQLLabUrl/openOnSqlLab to the makeUrl + openInNewTab helpers', () => {
+  // Source-pin: lock the exact two-line shape the runtime behaviour depends
+  // on. `getSQLLabUrl` MUST wrap its `/sqllab/?...` path in `makeUrl` so the
+  // Layer-2 idempotent prefix runs at the click boundary; `openOnSqlLab`
+  // MUST delegate to `openInNewTab` so `ensureAppRoot` runs again (idempotent
+  // dedupe, see `navigationUtils.appRoot.test.tsx`). A refactor that drops
+  // either layer would let a doubled-prefix URL escape into a new tab.
+  // eslint-disable-next-line global-require
+  const { readFileSync } = require('fs');
+  // eslint-disable-next-line global-require
+  const { join } = require('path');
+  const src = readFileSync(
+    join(__dirname, '..', 'DatasourceEditor.tsx'),
+    'utf8',
+  );
+  expect(src).toMatch(
+    /return makeUrl\(`\/sqllab\/\?\$\{queryParams\.toString\(\)\}`\);/,
+  );
+  expect(src).toMatch(/openInNewTab\(getSQLLabUrl\(\)\);/);
+  expect(src).toMatch(
+    /import \{ makeUrl, openInNewTab \} from 'src\/utils\/navigationUtils';/,
+  );
+});
diff --git a/superset-frontend/src/components/FacePile/index.tsx b/superset-frontend/src/components/FacePile/index.tsx
index 220209afc2d..5fefd307d25 100644
--- a/superset-frontend/src/components/FacePile/index.tsx
+++ b/superset-frontend/src/components/FacePile/index.tsx
@@ -24,7 +24,7 @@ import {
 } from '@superset-ui/core';
 import getOwnerName from 'src/utils/getOwnerName';
 import { Avatar, AvatarGroup, Tooltip } from '@superset-ui/core/components';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import { getRandomColor } from './utils';
 import type { FacePileProps } from './types';
 
diff --git a/superset-frontend/src/components/ListView/CrossLinks.test.tsx b/superset-frontend/src/components/ListView/CrossLinks.test.tsx
index 403b94a084c..ae2b5f0ec8f 100644
--- a/superset-frontend/src/components/ListView/CrossLinks.test.tsx
+++ b/superset-frontend/src/components/ListView/CrossLinks.test.tsx
@@ -68,10 +68,9 @@ test('should render the link with just one item', () => {
     ],
   });
   expect(screen.getByText('Test dashboard')).toBeInTheDocument();
-  expect(screen.getByRole('link')).toHaveAttribute(
-    'href',
-    `/superset/dashboard/1`,
-  );
+  // default `linkPrefix` is now `/dashboard/` (post-route_base);
+  // legacy `/superset/dashboard/...` was the pre-collapse route.
+  expect(screen.getByRole('link')).toHaveAttribute('href', `/dashboard/1`);
 });
 
 test('should render a custom prefix link', () => {
diff --git a/superset-frontend/src/components/ListView/CrossLinks.tsx b/superset-frontend/src/components/ListView/CrossLinks.tsx
index 6f4cd6d262b..d3f6f0a474c 100644
--- a/superset-frontend/src/components/ListView/CrossLinks.tsx
+++ b/superset-frontend/src/components/ListView/CrossLinks.tsx
@@ -65,7 +65,7 @@ const StyledCrossLinks = styled.div`
 function CrossLinks({
   crossLinks,
   maxLinks = 20,
-  linkPrefix = '/superset/dashboard/',
+  linkPrefix = '/dashboard/',
   external = false,
 }: CrossLinksProps) {
   const [crossLinksRef, plusRef, elementsTruncated, hasHiddenElements] =
diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
index 4a8aa0fe0c3..617e93b9288 100644
--- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
+++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
@@ -19,7 +19,7 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { SupersetClient } from '@superset-ui/core';
 import { ExportStatus, StreamingProgress } from './StreamingExportModal';
-import { makeUrl } from 'src/utils/pathUtils';
+import { makeUrl } from 'src/utils/navigationUtils';
 import { applicationRoot } from 'src/utils/getBootstrapData';
 
 interface UseStreamingExportOptions {
@@ -38,8 +38,8 @@ interface StreamingExportParams {
    * The API endpoint URL for the export request.
    *
    * URLs should be prefixed with the application root at the call site using
-   * `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for
-   * subdirectory deployments (e.g., /superset/api/v1/...).
+   * `makeUrl()` from `src/utils/navigationUtils`. This ensures proper handling
+   * for subdirectory deployments (e.g., /superset/api/v1/...).
    *
    * A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing,
    * but callers should not rely on this fallback behavior.
diff --git a/superset-frontend/src/components/Tag/index.tsx b/superset-frontend/src/components/Tag/index.tsx
index d09334e175b..0605977bc08 100644
--- a/superset-frontend/src/components/Tag/index.tsx
+++ b/superset-frontend/src/components/Tag/index.tsx
@@ -82,7 +82,7 @@ const SupersetTag = ({
             {' '}
             {id ? (
               <Link
-                to={`/superset/all_entities/?id=${id}`}
+                to={`/all_entities/?id=${id}`}
                 target="_blank"
                 rel="noreferrer"
               >
diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts
index e7c2fbe183d..c717d3779ef 100644
--- a/superset-frontend/src/core/navigation/index.test.ts
+++ b/superset-frontend/src/core/navigation/index.test.ts
@@ -35,12 +35,12 @@ test('getPage falls back to "home" for the welcome page and unknown pathnames',
   const { navigation, notifyLocationChanged } = await importNavigation();
   // The default pathname ('/') is not enumerated and falls back to home.
   expect(navigation.getPage()).toBe('home');
-  notifyLocationChanged('/superset/welcome/');
+  notifyLocationChanged('/welcome/');
   expect(navigation.getPage()).toBe('home');
 });
 
 test('getPage derives the page from window.location.pathname', async () => {
-  window.location.pathname = '/superset/dashboard/42/';
+  window.location.pathname = '/dashboard/42/';
   const { navigation } = await importNavigation();
   expect(navigation.getPage()).toBe('dashboard');
 });
@@ -56,19 +56,19 @@ test('notifyLocationChanged fires listeners on page type change', async () => {
   const listener = jest.fn();
   const disposable = navigation.onDidChangePage(listener);
 
-  notifyLocationChanged('/superset/dashboard/1/');
+  notifyLocationChanged('/dashboard/1/');
   expect(listener).toHaveBeenCalledWith('dashboard');
 
   disposable.dispose();
 });
 
 test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
-  window.location.pathname = '/superset/dashboard/1/';
+  window.location.pathname = '/dashboard/1/';
   const { navigation, notifyLocationChanged } = await importNavigation();
   const listener = jest.fn();
   navigation.onDidChangePage(listener);
 
-  notifyLocationChanged('/superset/dashboard/2/');
+  notifyLocationChanged('/dashboard/2/');
   expect(listener).not.toHaveBeenCalled();
 });
 
@@ -78,7 +78,7 @@ test('onDidChangePage listener is removed after dispose', async () => {
   const disposable = navigation.onDidChangePage(listener);
 
   disposable.dispose();
-  notifyLocationChanged('/superset/dashboard/1/');
+  notifyLocationChanged('/dashboard/1/');
   expect(listener).not.toHaveBeenCalled();
 });
 
diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts
index d41e76e50bf..2e2a61d3fb4 100644
--- a/superset-frontend/src/core/navigation/index.ts
+++ b/superset-frontend/src/core/navigation/index.ts
@@ -36,8 +36,12 @@ type Page = navigationApi.Page;
 
 /** Maps route path patterns to their corresponding Page type. */
 const PAGE_ROUTES: { path: string; page: Page }[] = [
-  { path: RoutePaths.DASHBOARD, page: 'dashboard' },
+  // List routes must precede their parameterised detail counterparts: with
+  // prefix-free paths, `matchPath(exact: false)` lets `/dashboard/:idOrSlug/`
+  // greedily capture `/dashboard/list/` (idOrSlug='list'), so the more specific
+  // list route has to win first — mirroring the `routes.tsx` Switch precedence.
   { path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
+  { path: RoutePaths.DASHBOARD, page: 'dashboard' },
   { path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
   { path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
   { path: RoutePaths.SQLLAB, page: 'sqllab' },
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.test.ts b/superset-frontend/src/dashboard/actions/dashboardState.test.ts
index 34736df6513..89af8b335f5 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.test.ts
+++ b/superset-frontend/src/dashboard/actions/dashboardState.test.ts
@@ -54,7 +54,7 @@ import {
 } from 'spec/fixtures/mockSliceEntities';
 import { emptyFilters } from 'spec/fixtures/mockDashboardFilters';
 import mockDashboardData from 'spec/fixtures/mockDashboardData';
-import { navigateTo } from 'src/utils/navigationUtils';
+import { navigateTo, navigateWithState } from 'src/utils/navigationUtils';
 
 jest.mock('@superset-ui/core', () => ({
   ...jest.requireActual('@superset-ui/core'),
@@ -72,6 +72,7 @@ jest.mock('src/utils/navigationUtils', () => ({
 
 const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
 const mockNavigateTo = navigateTo as jest.Mock;
+const mockNavigateWithState = navigateWithState as jest.Mock;
 
 // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
 describe('dashboardState actions', () => {
@@ -253,7 +254,48 @@ describe('dashboardState actions', () => {
 
       await waitFor(() => expect(postStub.mock.calls.length).toBe(1));
       expect(mockNavigateTo).toHaveBeenCalledWith(
-        `/superset/dashboard/${newDashboardId}/`,
+        `/dashboard/${newDashboardId}/`,
+      );
+    });
+
+    // `navigateWithState` regression for the
+    // dashboard-properties-changed save path. Two assertions in one shape:
+    //   (a) the emitted path is router-relative (`/dashboard/<id>/`), not
+    //       the pre-migration `/superset/dashboard/<id>/` literal that under
+    //       subdirectory deployment would double-prefix to
+    //       `/superset/superset/dashboard/<id>/`;
+    //   (b) the `event: 'dashboard_properties_changed'` history-state arg is
+    //       preserved verbatim. A previous attempt to swap `navigateWithState`
+    //       for a plain `navigateTo` would silently drop this state object and
+    //       the dashboard would lose its post-save UX cue.
+    test('saves dashboard properties via navigateWithState with state preserved', async () => {
+      const updatedId = 777;
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+
+      mockNavigateWithState.mockClear();
+      putStub.mockRestore();
+      putStub = jest.spyOn(SupersetClient, 'put').mockResolvedValue({
+        json: {
+          result: { ...mockDashboardData, id: updatedId, slug: null },
+          last_modified_time: 0,
+        },
+      } as any);
+
+      const thunk = saveDashboardRequest(
+        newDashboardData,
+        updatedId,
+        SAVE_TYPE_OVERWRITE,
+      );
+      await thunk(dispatch, getState);
+
+      await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
+      await waitFor(() => expect(mockNavigateWithState).toHaveBeenCalled());
+
+      expect(mockNavigateWithState).toHaveBeenCalledWith(
+        `/dashboard/${updatedId}/`,
+        { event: 'dashboard_properties_changed' },
       );
     });
   });
@@ -513,13 +555,11 @@ describe('dashboardState actions', () => {
       });
 
       getStub.mockRestore();
-      getStub = jest
-        .spyOn(SupersetClient, 'get')
-        .mockRejectedValue(
-          new Response(JSON.stringify({ message: 'Not found' }), {
-            status: 404,
-          }),
-        );
+      getStub = jest.spyOn(SupersetClient, 'get').mockRejectedValue(
+        new Response(JSON.stringify({ message: 'Not found' }), {
+          status: 404,
+        }),
+      );
 
       await fetchFaveStar(id)(dispatch, getState);
 
@@ -536,13 +576,11 @@ describe('dashboardState actions', () => {
       });
 
       getStub.mockRestore();
-      getStub = jest
-        .spyOn(SupersetClient, 'get')
-        .mockRejectedValue(
-          new Response(JSON.stringify({ message: 'Server error' }), {
-            status: 500,
-          }),
-        );
+      getStub = jest.spyOn(SupersetClient, 'get').mockRejectedValue(
+        new Response(JSON.stringify({ message: 'Server error' }), {
+          status: 500,
+        }),
+      );
 
       await fetchFaveStar(id)(dispatch, getState);
 
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.ts b/superset-frontend/src/dashboard/actions/dashboardState.ts
index a5af637cce1..397e29a2d7f 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.ts
+++ b/superset-frontend/src/dashboard/actions/dashboardState.ts
@@ -584,9 +584,7 @@ export function saveDashboardRequest(
         }),
       );
       dispatch(saveDashboardFinished());
-      navigateTo(
-        `/superset/dashboard/${(response.json as JsonObject).result?.id}/`,
-      );
+      navigateTo(`/dashboard/${(response.json as JsonObject).result?.id}/`);
       dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
       return response;
     };
@@ -639,7 +637,7 @@ export function saveDashboardRequest(
       }
       dispatch(saveDashboardFinished());
       // redirect to the new slug or id
-      navigateWithState(`/superset/dashboard/${slug || id}/`, {
+      navigateWithState(`/dashboard/${slug || id}/`, {
         event: 'dashboard_properties_changed',
       });
 
diff --git a/superset-frontend/src/dashboard/actions/datasources.ts b/superset-frontend/src/dashboard/actions/datasources.ts
index f48c1242845..adb5d057be2 100644
--- a/superset-frontend/src/dashboard/actions/datasources.ts
+++ b/superset-frontend/src/dashboard/actions/datasources.ts
@@ -62,7 +62,7 @@ export function fetchDatasourceMetadata(key: string) {
     }
 
     return SupersetClient.get({
-      endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${key}`,
+      endpoint: `/fetch_datasource_metadata?datasourceKey=${key}`,
     }).then(({ json }) => dispatch(setDatasource(json as Datasource, key)));
   };
 }
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
index 0a934a37e2f..a3be526a82d 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
@@ -48,7 +48,7 @@ import * as useNativeFiltersModule from './state';
 fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
 fetchMock.put('glob:*/api/v1/dashboard/*', {});
 // Add mock for logging endpoint
-fetchMock.post('glob:*/superset/log/?*', {});
+fetchMock.post('glob:*/log/?*', {});
 
 jest.mock('src/dashboard/actions/dashboardState', () => ({
   ...jest.requireActual('src/dashboard/actions/dashboardState'),
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
index 5f70f3319e6..d941b9f2324 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
@@ -29,7 +29,7 @@ import * as chartCustomizationActions from '../../actions/chartCustomizationActi
 
 fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
 fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {});
-fetchMock.post('glob:*/superset/log/?*', {});
+fetchMock.post('glob:*/log/?*', {});
 
 jest.mock('@visx/responsive', () => ({
   useParentSize: () => ({ parentRef: { current: null }, width: 800 }),
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx
new file mode 100644
index 00000000000..e58f036b3cc
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.subdirectory.test.tsx
@@ -0,0 +1,172 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { VizType } from '@superset-ui/core';
+import mockState from 'spec/fixtures/mockState';
+import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
+
+// Subdirectory-specific regressions live here so the existing 676-line
+// SliceHeaderControls.test.tsx doesn't need to mock getBootstrapData.
+
+// DO NOT switch this file to
+// `spec/helpers/withApplicationRoot.ts`. The fixture does
+// `jest.resetModules()` + dynamic `import('src/utils/getBootstrapData')` to
+// install a fixture-configured applicationRoot. But `SliceHeaderControls` is
+// imported statically at the top of this file; its transitive dependency
+// chain (`SliceHeaderControls` → `navigationUtils` → `pathUtils` →
+// `getBootstrapData::applicationRoot`) is bound to the pre-reset module
+// instance. After `withApplicationRoot('/superset')` resets modules and
+// re-imports getBootstrapData on the test side, the statically-imported
+// component continues to reach the OLD module whose `application_root` was
+// empty at first evaluation — so the rendered tree resolves
+// `applicationRoot()` to `''`, NOT `/superset`. Gate (a) of the M7 go/no-go
+// fails ("the rendered SliceHeaderControls tree must resolve
+// applicationRoot() to the fixture-configured value"). The hand-rolled
+// `jest.mock('src/utils/getBootstrapData', ...)` below remains until a
+// later slice either (i) defers the SliceHeaderControls import into the
+// withApplicationRoot callback or (ii) plumbs application_root through
+// React context rather than a module-scoped cache.
+
+// Name must start with `mock` so Jest's hoisted jest.mock() factory may
+// reference it. `default` returns a static shape (not mockApplicationRoot)
+// because consumers like setupClient.ts call getBootstrapData() at import
+// time — calling mockApplicationRoot inside `default` hits TDZ.
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: () => ({
+    common: { application_root: '', static_assets_prefix: '' },
+  }),
+  applicationRoot: () => mockApplicationRoot(),
+  staticAssetsPrefix: () => '',
+}));
+
+const SLICE_ID = 371;
+
+const buildProps = (): SliceHeaderControlsProps =>
+  ({
+    addDangerToast: jest.fn(),
+    addSuccessToast: jest.fn(),
+    exploreChart: jest.fn(),
+    exportCSV: jest.fn(),
+    exportFullCSV: jest.fn(),
+    exportXLSX: jest.fn(),
+    exportFullXLSX: jest.fn(),
+    exportPivotExcel: jest.fn(),
+    forceRefresh: jest.fn(),
+    handleToggleFullSize: jest.fn(),
+    toggleExpandSlice: jest.fn(),
+    logEvent: jest.fn(),
+    logExploreChart: jest.fn(),
+    slice: {
+      slice_id: SLICE_ID,
+      slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
+      slice_name: 'Subdirectory regression chart',
+      slice_description: '',
+      form_data: {
+        slice_id: SLICE_ID,
+        datasource: '58__table',
+        viz_type: VizType.Sunburst,
+      },
+      viz_type: VizType.Sunburst,
+      datasource: '58__table',
+      description: '',
+      description_markeddown: '',
+      owners: [],
+      modified: '',
+      changed_on: 0,
+    },
+    isCached: [false],
+    isExpanded: false,
+    cachedDttm: [''],
+    updatedDttm: 0,
+    supersetCanExplore: true,
+    supersetCanCSV: true,
+    componentId: 'CHART-subdir',
+    dashboardId: 26,
+    isFullSize: false,
+    chartStatus: 'rendered',
+    showControls: true,
+    supersetCanShare: true,
+    formData: {
+      slice_id: SLICE_ID,
+      datasource: '58__table',
+      viz_type: VizType.Sunburst,
+    },
+    exploreUrl: '/explore/?dashboard_page_id=abc&slice_id=371',
+    defaultOpen: true,
+  }) as unknown as SliceHeaderControlsProps;
+
+const renderControls = (): void => {
+  render(<SliceHeaderControls {...buildProps()} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState: {
+      ...mockState,
+      user: {
+        ...mockState.user,
+        roles: { Admin: [['can_samples', 'Datasource']] },
+      },
+    },
+  });
+};
+
+describe('SliceHeaderControls — Cmd-click "Edit chart" under subdirectory deployment', () => {
+  let openSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    mockApplicationRoot.mockReturnValue('');
+    openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    openSpy.mockRestore();
+  });
+
+  test('opens the unprefixed exploreUrl when application root is empty', async () => {
+    mockApplicationRoot.mockReturnValue('');
+    renderControls();
+
+    userEvent.click(screen.getByRole('button', { name: 'More Options' }));
+    const editChart = await screen.findByText('Edit chart');
+    userEvent.click(editChart, { metaKey: true });
+
+    expect(openSpy).toHaveBeenCalledWith(
+      '/explore/?dashboard_page_id=abc&slice_id=371',
+      '_blank',
+      'noopener noreferrer',
+    );
+  });
+
+  test('opens the prefixed exploreUrl when deployed under a subdirectory', async () => {
+    mockApplicationRoot.mockReturnValue('/superset');
+    renderControls();
+
+    userEvent.click(screen.getByRole('button', { name: 'More Options' }));
+    const editChart = await screen.findByText('Edit chart');
+    userEvent.click(editChart, { metaKey: true });
+
+    expect(openSpy).toHaveBeenCalledWith(
+      '/superset/explore/?dashboard_page_id=abc&slice_id=371',
+      '_blank',
+      'noopener noreferrer',
+    );
+  });
+});
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index a903787e215..250b149dfd2 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -57,6 +57,7 @@ import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenu
 import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
 import { MenuKeys, RootState } from 'src/dashboard/types';
 import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
+import { openInNewTab } from 'src/utils/navigationUtils';
 import { usePermissions } from 'src/hooks/usePermissions';
 import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
 import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
@@ -263,7 +264,7 @@ const SliceHeaderControls = (
         props.logExploreChart?.(props.slice.slice_id);
         if (domEvent.metaKey || domEvent.ctrlKey) {
           domEvent.preventDefault();
-          window.open(props.exploreUrl, '_blank');
+          openInNewTab(props.exploreUrl);
         } else {
           history.push(props.exploreUrl);
         }
diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx
index fe4d92cea0d..326a3bd3aa5 100644
--- a/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx
+++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/URLShortLinkButton.test.tsx
@@ -26,6 +26,9 @@ const PERMALINK_PAYLOAD = {
   key: '123',
   url: 'http://fakeurl.com/123',
 };
+// rewritePermalinkOrigin substitutes window.location.origin (jsdom: http://localhost)
+// for the permalink's origin while preserving the path. See urlUtils.ts.
+const REWRITTEN_URL = `${window.location.origin}/123`;
 const FILTER_STATE_PAYLOAD = {
   value: '{}',
 };
@@ -58,9 +61,7 @@ test('renders overlay on click', async () => {
 test('obtains short url', async () => {
   render(<URLShortLinkButton {...props} />, { useRedux: true });
   userEvent.click(screen.getByRole('button'));
-  expect(await screen.findByRole('tooltip')).toHaveTextContent(
-    PERMALINK_PAYLOAD.url,
-  );
+  expect(await screen.findByRole('tooltip')).toHaveTextContent(REWRITTEN_URL);
 });
 
 test('creates email anchor', async () => {
@@ -78,7 +79,7 @@ test('creates email anchor', async () => {
     },
   );
 
-  const href = `mailto:?Subject=${subject}%20&Body=${content}${PERMALINK_PAYLOAD.url}`;
+  const href = `mailto:?Subject=${subject}%20&Body=${content}${REWRITTEN_URL}`;
   userEvent.click(screen.getByRole('button'));
   expect(await screen.findByRole('link')).toHaveAttribute('href', href);
 });
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx
new file mode 100644
index 00000000000..c26c807dd5e
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.subdirectory.test.tsx
@@ -0,0 +1,146 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { FC } from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+
+// Tab.tsx is statically imported below; the mock pattern intercepts
+// applicationRoot() rather than relying on withApplicationRoot (which is for
+// dynamic-import unit tests).
+
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    default: actual.default,
+    applicationRoot: () => mockApplicationRoot(),
+    staticAssetsPrefix: actual.staticAssetsPrefix,
+  };
+});
+
+jest.mock('src/dashboard/util/getChartIdsFromComponent', () =>
+  jest.fn(() => []),
+);
+jest.mock('src/dashboard/containers/DashboardComponent', () =>
+  jest.fn(() => <div data-test="DashboardComponent" />),
+);
+jest.mock('@superset-ui/core/components/EditableTitle', () => ({
+  __esModule: true,
+  EditableTitle: jest.fn(() => <div data-test="EditableTitle" />),
+}));
+jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
+  ...jest.requireActual('src/dashboard/components/dnd/DragDroppable'),
+  Droppable: jest.fn(props => (
+    <div>{props.children ? props.children({}) : null}</div>
+  )),
+}));
+
+// eslint-disable-next-line import/first
+import ActualTab from './Tab';
+
+const Tab = ActualTab as unknown as FC<Record<string, unknown>>;
+
+const DASHBOARD_ID = 23;
+
+const buildProps = () => ({
+  id: 'TAB-empty-',
+  parentId: 'TABS-empty-',
+  depth: 2,
+  index: 0,
+  renderType: 'RENDER_TAB_CONTENT',
+  availableColumnCount: 12,
+  columnWidth: 120,
+  isFocused: false,
+  component: {
+    children: [],
+    id: 'TAB-empty-',
+    meta: { text: 'Empty Tab' },
+    parents: ['ROOT_ID', 'GRID_ID', 'TABS-empty-'],
+    type: 'TAB',
+  },
+  parentComponent: {
+    children: ['TAB-empty-'],
+    id: 'TABS-empty-',
+    meta: {},
+    parents: ['ROOT_ID', 'GRID_ID'],
+    type: 'TABS',
+  },
+  editMode: true,
+  embeddedMode: false,
+  undoLength: 0,
+  redoLength: 0,
+  filters: {},
+  directPathToChild: [],
+  directPathLastUpdated: 0,
+  dashboardId: DASHBOARD_ID,
+  focusedFilterScope: null,
+  isComponentVisible: true,
+  onDropOnTab: jest.fn(),
+  handleComponentDrop: jest.fn(),
+  updateComponents: jest.fn(),
+  setDirectPathToChild: jest.fn(),
+  onResizeStart: jest.fn(),
+  onResize: jest.fn(),
+  onResizeStop: jest.fn(),
+});
+
+const renderEmptyEditModeTab = () =>
+  render(<Tab {...buildProps()} />, {
+    useRedux: true,
+    useDnd: true,
+    initialState: {
+      dashboardInfo: { dash_edit_perm: true },
+    },
+  });
+
+beforeEach(() => {
+  mockApplicationRoot.mockReturnValue('');
+});
+
+test('Tab — empty edit-mode "create a new chart" link is unprefixed when application root is empty', () => {
+  mockApplicationRoot.mockReturnValue('');
+  renderEmptyEditModeTab();
+
+  expect(
+    screen.getByRole('link', { name: 'create a new chart' }),
+  ).toHaveAttribute('href', `/chart/add?dashboard_id=${DASHBOARD_ID}`);
+});
+
+test('Tab — empty edit-mode "create a new chart" link carries the application root under subdirectory deployment', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  renderEmptyEditModeTab();
+
+  // Single prefix — not /superset/superset/ — verifying ensureAppRoot's
+  // dedupe boundary holds against the path's leading slash.
+  expect(
+    screen.getByRole('link', { name: 'create a new chart' }),
+  ).toHaveAttribute('href', `/superset/chart/add?dashboard_id=${DASHBOARD_ID}`);
+});
+
+test('Tab — empty edit-mode "create a new chart" link prefixes correctly for nested subdirectory roots', () => {
+  mockApplicationRoot.mockReturnValue('/a/b/c');
+  renderEmptyEditModeTab();
+
+  expect(
+    screen.getByRole('link', { name: 'create a new chart' }),
+  ).toHaveAttribute('href', `/a/b/c/chart/add?dashboard_id=${DASHBOARD_ID}`);
+});
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx
index 23e5a5deedd..2815007eee6 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx
@@ -27,6 +27,7 @@ import {
 import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
 import { EditableTitle } from '@superset-ui/core/components';
 import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
+import * as getBootstrapData from 'src/utils/getBootstrapData';
 
 import type { FC } from 'react';
 import ActualTab from './Tab';
@@ -488,6 +489,36 @@ test('Render tab content with no children, editMode: true, canEdit: true', () =>
   ).toHaveAttribute('href', '/chart/add?dashboard_id=23');
 });
 
+test('empty-tab "create a new chart" link is single-prefixed under subdirectory deployment', () => {
+  // The empty-tab CTA composes the chart-add URL via ensureAppRoot. Under
+  // SUPERSET_APP_ROOT=/superset the rendered href must be exactly
+  // `/superset/chart/add?dashboard_id=23` — not `/chart/add?…` (no prefix)
+  // and not `/superset/superset/chart/add?…` (double prefix). The link uses
+  // target="_blank", so basename routing does NOT re-apply the prefix.
+  const applicationRootSpy = jest
+    .spyOn(getBootstrapData, 'applicationRoot')
+    .mockReturnValue('/superset');
+
+  try {
+    const props = createProps();
+    props.editMode = true;
+    props.component.children = [];
+    render(<Tab {...props} />, {
+      useRedux: true,
+      useDnd: true,
+      initialState: {
+        dashboardInfo: { dash_edit_perm: true },
+      },
+    });
+
+    expect(
+      screen.getByRole('link', { name: 'create a new chart' }),
+    ).toHaveAttribute('href', '/superset/chart/add?dashboard_id=23');
+  } finally {
+    applicationRootSpy.mockRestore();
+  }
+});
+
 test('Drag to empty state, editMode: true, canEdit: true', async () => {
   const props = createProps();
   props.editMode = true;
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
index f2486a80dc8..9bdbf9221c2 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx
@@ -36,6 +36,7 @@ import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponen
 import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
 import AnchorLink from 'src/dashboard/components/AnchorLink';
 import { Typography } from '@superset-ui/core/components/Typography';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import {
   useIsAutoRefreshing,
   useIsRefreshInFlight,
@@ -333,7 +334,9 @@ const Tab = (props: TabProps): ReactElement => {
                       <span>
                         {t('You can')}{' '}
                         <Typography.Link
-                          href={`/chart/add?dashboard_id=${dashboardId}`}
+                          href={ensureAppRoot(
+                            `/chart/add?dashboard_id=${dashboardId}`,
+                          )}
                           rel="noopener noreferrer"
                           target="_blank"
                         >
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts
new file mode 100644
index 00000000000..212939941d8
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts
@@ -0,0 +1,207 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// `FilterBar/index.tsx::publishDataMask` is one of the five sanctioned
+// `applicationRoot()` callers (memory `project_supersetclient_approot_dedupe`).
+// It runs after a filter mutation to push the updated filter cache key into
+// the URL via `history.replace`. Two appRoot-aware operations gate that
+// replace:
+//
+//   1. The path-matching guard — only fire when the current pathname is a
+//      dashboard route under the configured appRoot. The bug class this
+//      catches is "filter writes pollute Explore's URL after navigation".
+//
+//   2. The prefix-strip — React Router applies `basename` internally, so
+//      `history.replace({ pathname })` must receive a path WITHOUT the
+//      appRoot. The bug class this catches is `/superset/superset/dashboard/...`
+//      in the URL bar after the first filter change.
+//
+// `publishDataMask` is module-private (declared as a `const debounce(...)`).
+// Testing it through a rendered FilterBar requires the Redux store, the
+// filter cache API, and the debounce timer — heavyweight relative to what
+// the contract actually says. Instead this test does two things:
+//
+//   A. Reads FilterBar/index.tsx as source and pins the two patterns that
+//      embody the contract. A future refactor that drops the guard or the
+//      strip fails here loudly with the exact line that drifted.
+//   B. Tests the *equivalent* pure logic (re-implementation of the same
+//      pattern) across every appRoot × pathname × Explore-vs-Dashboard
+//      input shape that matters in practice. If the actual code drifts
+//      from the documented invariant, the source-pin in (A) fires; if the
+//      documented invariant itself is wrong, (B) fires.
+
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+const FILTERBAR_SRC = readFileSync(join(__dirname, 'index.tsx'), 'utf8');
+
+// ---------------------------------------------------------------------------
+// (A) Source-pin: the two patterns that implement the contract.
+// ---------------------------------------------------------------------------
+
+test('FilterBar/index.tsx guards history.replace by the configured app root', () => {
+  // The guard short-circuits the URL mutation when the current path is not a
+  // dashboard route under the appRoot — e.g. when the user navigated to
+  // Explore (`/explore/?slice_id=...`), the FilterBar's debounced commit must
+  // not stomp Explore's query string with native_filters_key.
+  expect(FILTERBAR_SRC).toContain(
+    'window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)',
+  );
+});
+
+test('FilterBar/index.tsx strips the app root before history.replace', () => {
+  // Both halves of the strip survive together — the appRoot != "/" check
+  // and the startsWith-before-substring guard. Each is load-bearing on its
+  // own (without the first, root deploy hits `.substring(1)` and clips off
+  // the leading slash; without the second, paths that diverge from the
+  // appRoot get incorrectly truncated).
+  expect(FILTERBAR_SRC).toContain(
+    "if (appRoot !== '/' && replacementPathname.startsWith(appRoot))",
+  );
+  expect(FILTERBAR_SRC).toContain(
+    'replacementPathname = replacementPathname.substring(appRoot.length);',
+  );
+});
+
+test('FilterBar/index.tsx imports applicationRoot from getBootstrapData', () => {
+  // Centralised symbol — the static-scan invariant in
+  // navigationUtils.invariants.test.ts enumerates the sanctioned import
+  // sites. If FilterBar's import path drifts, that scan also fires; this
+  // one anchors the import locally so a `git blame` on FilterBar tells the
+  // story without needing to cross-reference the scan ledger.
+  expect(FILTERBAR_SRC).toMatch(
+    /import\s+\{\s*applicationRoot\s*\}\s+from\s+'src\/utils\/getBootstrapData'/,
+  );
+});
+
+// ---------------------------------------------------------------------------
+// (B) Characterisation: the documented invariant, exercised across the
+//     appRoot × pathname matrix.
+// ---------------------------------------------------------------------------
+//
+// Re-implementation of the FilterBar guard + strip, kept here so the test
+// can fail loudly if the *invariant itself* is wrong (rather than a typo in
+// the implementation). The source-pin above catches the inverse case
+// (implementation drifts away from the invariant).
+
+interface Scenario {
+  description: string;
+  appRoot: string;
+  pathname: string;
+  shouldReplace: boolean;
+  replacementPathname?: string;
+}
+
+function applyFilterBarPathLogic(
+  appRoot: string,
+  pathname: string,
+): { shouldReplace: boolean; replacementPathname?: string } {
+  if (!pathname.startsWith(`${appRoot}/dashboard`)) {
+    return { shouldReplace: false };
+  }
+  let replacement = pathname;
+  if (appRoot !== '/' && replacement.startsWith(appRoot)) {
+    replacement = replacement.substring(appRoot.length);
+  }
+  return { shouldReplace: true, replacementPathname: replacement };
+}
+
+const SCENARIOS: ReadonlyArray<Scenario> = [
+  // Root deploy — pathname matches `/dashboard` directly.
+  {
+    description: 'root deploy on a dashboard page',
+    appRoot: '',
+    pathname: '/dashboard/1/',
+    shouldReplace: true,
+    replacementPathname: '/dashboard/1/',
+  },
+  {
+    description: 'root deploy on Explore — guard short-circuits',
+    appRoot: '',
+    pathname: '/explore/?slice_id=42',
+    shouldReplace: false,
+  },
+  // Subdir deploy — appRoot is `/superset`, pathname carries the prefix.
+  {
+    description: 'subdir deploy on a dashboard page',
+    appRoot: '/superset',
+    pathname: '/superset/dashboard/2/',
+    shouldReplace: true,
+    // Stripped: React Router re-applies basename so the strip MUST happen.
+    replacementPathname: '/dashboard/2/',
+  },
+  {
+    description: 'subdir deploy on a dashboard permalink',
+    appRoot: '/superset',
+    pathname: '/superset/dashboard/p/abc123/',
+    shouldReplace: true,
+    replacementPathname: '/dashboard/p/abc123/',
+  },
+  {
+    description: 'subdir deploy on Explore — guard short-circuits',
+    appRoot: '/superset',
+    pathname: '/superset/explore/?slice_id=7',
+    shouldReplace: false,
+  },
+  {
+    description:
+      'subdir deploy on bare app root (no /dashboard) — short-circuits',
+    appRoot: '/superset',
+    pathname: '/superset/',
+    shouldReplace: false,
+  },
+  // Operator deploy under a deeply nested basename.
+  {
+    description: 'deep-nested deploy on a dashboard page',
+    appRoot: '/tenant-a/superset',
+    pathname: '/tenant-a/superset/dashboard/9/',
+    shouldReplace: true,
+    replacementPathname: '/dashboard/9/',
+  },
+  // Adversarial: appRoot `/dash` is a substring of `/dashboard`. The guard
+  // template is `${appRoot}/dashboard` so the prefix is `/dash/dashboard`,
+  // which (correctly) does NOT match a bare `/dashboard/1/` path. Pin the
+  // case so a maintainer doesn't "fix" the guard to also match prefix-free
+  // paths (which would re-introduce the Explore-stomp regression for
+  // operators whose root happens to share characters with `/dashboard`).
+  {
+    description:
+      'appRoot is a substring prefix of /dashboard — guard does NOT match a bare /dashboard path',
+    appRoot: '/dash',
+    pathname: '/dashboard/5/',
+    shouldReplace: false,
+  },
+];
+
+test.each(SCENARIOS)(
+  'publishDataMask path logic: $description',
+  ({ appRoot, pathname, shouldReplace, replacementPathname }: Scenario) => {
+    const result = applyFilterBarPathLogic(appRoot, pathname);
+    expect(result.shouldReplace).toBe(shouldReplace);
+    if (shouldReplace) {
+      expect(result.replacementPathname).toBe(replacementPathname);
+      // The dedupe contract: no `/superset/superset/...` ever reaches React
+      // Router. Even if the source-pin drifts, this catches the user-visible
+      // symptom.
+      expect(result.replacementPathname).not.toMatch(/\/superset\/superset\//);
+    } else {
+      expect(result.replacementPathname).toBeUndefined();
+    }
+  },
+);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
index ef9f3dc69a1..dcb8b0532bd 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
@@ -142,9 +142,11 @@ const publishDataMask = debounce(
 
     // pathname could be updated somewhere else through window.history
     // keep react router history in sync with window history
-    // replace params only when current page is /superset/dashboard
+    // replace params only when current page is a dashboard route under the
+    // configured applicationRoot (e.g. `/dashboard/...` for root deploy,
+    // `/superset/dashboard/...` for the legacy subdir deploy).
     // this prevents a race condition between updating filters and navigating to Explore
-    if (window.location.pathname.includes('/superset/dashboard')) {
+    if (window.location.pathname.startsWith(`${applicationRoot()}/dashboard`)) {
       // The history API is part of React router and understands that a basename may exist.
       // Internally it treats all paths as if they are relative to the root and appends
       // it when necessary. We strip any prefix so that history.replace adds it back and doesn't
diff --git a/superset-frontend/src/dashboard/util/risonFilters.ts b/superset-frontend/src/dashboard/util/risonFilters.ts
index d9b4ead12a0..9aa1678c21e 100644
--- a/superset-frontend/src/dashboard/util/risonFilters.ts
+++ b/superset-frontend/src/dashboard/util/risonFilters.ts
@@ -23,6 +23,7 @@ import {
   DataMaskStateWithId,
 } from '@superset-ui/core';
 import rison from 'rison';
+import { navigateWithState } from 'src/utils/navigationUtils';
 
 /**
  * Synthetic dataMask key for URL Rison filters that don't match any native
@@ -238,7 +239,17 @@ export function prettifyRisonFilterUrl(): void {
     const prettifiedUrl = `${beforeRison}${separator}f=${risonValue}${afterRison}`;
 
     if (prettifiedUrl !== currentUrl) {
-      window.history.replaceState(window.history.state, '', prettifiedUrl);
+      // Route through navigateWithState so the navigationUtils guards
+      // (`assertSafeNavigationUrl` scheme/userinfo barriers + the
+      // CodeQL-recognised inline sanitisers) apply at the
+      // `window.history.replaceState` sink. The URL constructor inside
+      // `navigateWithState` is conservative about re-encoding: sub-delims
+      // like `(`, `)`, `:`, `!` (the meaningful Rison glyphs) survive,
+      // so the prettification's visual win is preserved for every
+      // character the prettifier actually targets.
+      navigateWithState(prettifiedUrl, window.history.state ?? {}, {
+        replace: true,
+      });
     }
   } catch (error) {
     console.warn('Failed to prettify Rison URL:', error);
@@ -351,12 +362,11 @@ export function updateUrlWithUnmatchedFilters(
     // With a real `BrowserRouter`, `history.replace` would do this too — but
     // under a `createMemoryHistory` (used in tests, or in some embedded
     // contexts) it does not, and we'd leak the stale URL into the next
-    // `getRisonFilterParam()` call.
-    window.history.replaceState(
-      window.history.state,
-      '',
-      currentUrl.toString(),
-    );
+    // `getRisonFilterParam()` call. Routed through navigateWithState so the
+    // navigationUtils scheme/userinfo barriers gate the sink.
+    navigateWithState(currentUrl.toString(), window.history.state ?? {}, {
+      replace: true,
+    });
     if (history) {
       history.replace({
         pathname: currentUrl.pathname,
diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx b/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx
new file mode 100644
index 00000000000..69674314d77
--- /dev/null
+++ b/superset-frontend/src/explore/components/EmbedCodeContent.subdirectory.test.tsx
@@ -0,0 +1,78 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import fetchMock from 'fetch-mock';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
+
+// The chart-embed iframe `src` is produced by:
+//   1. Backend `url_for(_external=True)` → absolute URL whose origin is the
+//      backend `Host` header (often the internal docker hostname under
+//      `superset-light:8088` when `ENABLE_PROXY_FIX` is off).
+//   2. Frontend `rewritePermalinkOrigin` swaps the origin for
+//      `window.location.origin` so the iframe `src` is reachable from the
+//      browser that pasted the embed code.
+//   3. The path segment (`/superset/explore/p/<key>/`) survives unchanged —
+//      the application_root must therefore be applied exactly once.
+//
+// This composition was previously verified only via manual QA
+// (memory `project_supersetclient_approot_dedupe` records the discovery).
+// This test pins the iframe-src shape so a future change to the permalink
+// API, the origin-rewrite helper, or the EmbedCodeContent template would
+// surface in CI rather than in a user-reported broken embed.
+
+const SUBDIR_PERMALINK_URL =
+  'http://superset-light:8088/superset/explore/p/abc123/';
+
+fetchMock.post('glob:*/api/v1/explore/permalink', {
+  url: SUBDIR_PERMALINK_URL,
+});
+
+const mockFormData = {
+  datasource: 'table__1',
+  viz_type: 'table',
+};
+
+test('iframe src under subdir deployment uses browser origin + single prefix', async () => {
+  render(<EmbedCodeContent formData={mockFormData} />, { useRedux: true });
+
+  // The textarea `value` contains the full iframe HTML once the permalink
+  // promise resolves. `data-test="embed-code-textarea"` is the stable hook.
+  const textarea = await screen.findByTestId('embed-code-textarea');
+
+  // Wait for the asynchronous permalink fetch to land in the textarea.
+  await waitFor(() =>
+    expect((textarea as HTMLTextAreaElement).value).toContain('<iframe'),
+  );
+
+  const html = (textarea as HTMLTextAreaElement).value;
+  const srcMatch = html.match(/src="([^"]+)"/);
+  expect(srcMatch).not.toBeNull();
+  const src = (srcMatch as RegExpMatchArray)[1];
+
+  // Two contracts: origin is the browser-side origin (jsdom default
+  // `http://localhost`), and the `/superset/` prefix from the backend
+  // payload survives — exactly once.
+  const parsed = new URL(src);
+  expect(parsed.origin).toBe(window.location.origin);
+  expect(parsed.pathname).toBe('/superset/explore/p/abc123/');
+  expect(src).not.toContain('/superset/superset/');
+  // Standalone + height controls are appended additively by the component.
+  expect(parsed.searchParams.get('standalone')).toBe('1');
+  expect(parsed.searchParams.get('height')).toBe('400');
+});
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
index f04fef75472..d90765ab0cb 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
@@ -41,7 +41,7 @@ import TextControl from 'src/explore/components/controls/TextControl';
 import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
 import PopoverSection from '@superset-ui/core/components/PopoverSection';
 import ControlHeader from 'src/explore/components/ControlHeader';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import {
   ANNOTATION_SOURCE_TYPES,
   ANNOTATION_TYPES,
@@ -119,7 +119,13 @@ const NotFoundContent = () => (
         <span>
           {t('Add an annotation layer')}{' '}
           <a
-            href={ensureAppRoot('/annotationlayer/list')}
+            // encodeURI wraps the DOM-derived application-root prefix so
+            // CodeQL's `js/html-injection` sees a recognised through-function
+            // sanitiser between `applicationRoot()` (reads `data-bootstrap`
+            // from the DOM) and the `<a href>` sink. The string fed in is a
+            // URL-normalised path (`/seg/seg`) so encodeURI is idempotent in
+            // practice — it does not alter the navigation target.
+            href={encodeURI(ensureAppRoot('/annotationlayer/list'))}
             target="_blank"
             rel="noopener noreferrer"
           >
diff --git a/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
index 5fd67dafa07..e1b5c50c24b 100644
--- a/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
@@ -185,6 +185,7 @@ test('opens SQL Lab in a new tab when View in SQL Lab button is clicked with met
   expect(window.open).toHaveBeenCalledWith(
     `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(sql)}`,
     '_blank',
+    'noopener noreferrer',
   );
 });
 
diff --git a/superset-frontend/src/explore/components/controls/ViewQuery.tsx b/superset-frontend/src/explore/components/controls/ViewQuery.tsx
index 1d321d766e3..0befdd9cd82 100644
--- a/superset-frontend/src/explore/components/controls/ViewQuery.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQuery.tsx
@@ -40,7 +40,7 @@ import {
 import { CopyToClipboard } from 'src/components';
 import { RootState } from 'src/dashboard/types';
 import { findPermission } from 'src/utils/findPermission';
-import { makeUrl } from 'src/utils/pathUtils';
+import { openInNewTab } from 'src/utils/navigationUtils';
 import CodeSyntaxHighlighter, {
   SupportedLanguage,
   preloadLanguages,
@@ -140,11 +140,8 @@ const ViewQuery: FC<ViewQueryProps> = props => {
       };
       if (domEvent.metaKey || domEvent.ctrlKey) {
         domEvent.preventDefault();
-        window.open(
-          makeUrl(
-            `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`,
-          ),
-          '_blank',
+        openInNewTab(
+          `/sqllab?datasourceKey=${datasource}&sql=${encodeURIComponent(currentSQL)}`,
         );
       } else {
         history.push({ pathname: '/sqllab', state: { requestedQuery } });
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx
index 2fdd4e29bee..d1f8ff69902 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx
@@ -39,7 +39,7 @@ const TestDashboardsMenuItems = ({
     <div data-test="menu-items">
       {menuItems.map(item => (
         <div key={item.key} data-test={`menu-item-${item!.key}`}>
-          {typeof item.label === 'string' ? item!.label : 'Complex Label'}
+          {item!.label}
           {item!.disabled && <span data-test="disabled">disabled</span>}
         </div>
       ))}
@@ -173,6 +173,35 @@ describe('DashboardsSubMenu', () => {
     expect(screen.getByTestId('menu-item-5')).toBeInTheDocument();
   });
 
+  test('renders SPA-relative dashboard links without the /superset/ prefix', () => {
+    // Regression: prior to the route_base="" alignment, this menu emitted
+    // `to="/superset/dashboard/<id>"` which, combined with the React Router
+    // `basename={applicationRoot()}`, produced a doubled `/superset/superset/`
+    // path on subdirectory deployments and a backend 404.
+    const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }];
+    render(
+      <TestDashboardsMenuItems
+        chartId={102}
+        dashboards={dashboards}
+        searchTerm=""
+      />,
+      { useRouter: true },
+    );
+
+    const link = screen.getByRole('link', { name: /Sales Dashboard/ });
+    expect(link).toHaveAttribute('href', '/dashboard/9?focused_chart=102');
+  });
+
+  test('omits the focused_chart query when chartId is undefined', () => {
+    const dashboards = [{ id: 9, dashboard_title: 'Sales Dashboard' }];
+    render(<TestDashboardsMenuItems dashboards={dashboards} searchTerm="" />, {
+      useRouter: true,
+    });
+
+    const link = screen.getByRole('link', { name: /Sales Dashboard/ });
+    expect(link).toHaveAttribute('href', '/dashboard/9');
+  });
+
   test('partial string search works correctly', () => {
     const dashboards = [
       { id: 1, dashboard_title: 'Revenue Report' },
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx
index fcd2454615c..00b4a8a5f4f 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx
@@ -71,8 +71,8 @@ export const useDashboardsMenuItems = ({
           label: (
             <Link
               target="_blank"
-              rel="noreferer noopener"
-              to={`/superset/dashboard/${dashboard.id}${urlQueryString}`}
+              rel="noreferrer noopener"
+              to={`/dashboard/${dashboard.id}${urlQueryString}`}
               css={css`
                 display: flex;
                 flex-direction: row;
diff --git a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
index 9b8069faf62..4625fff6c89 100644
--- a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
+++ b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
@@ -66,7 +66,7 @@ describe('exploreUtils', () => {
         force: false,
         curUrl: 'http://superset.com',
       });
-      compareURI(URI(url!), URI('/superset/explore_json/'));
+      compareURI(URI(url!), URI('/explore_json/'));
     });
     test('generates proper json forced url', () => {
       const url = getExploreUrl({
@@ -75,10 +75,7 @@ describe('exploreUtils', () => {
         force: true,
         curUrl: 'superset.com',
       });
-      compareURI(
-        URI(url!),
-        URI('/superset/explore_json/').search({ force: 'true' }),
-      );
+      compareURI(URI(url!), URI('/explore_json/').search({ force: 'true' }));
     });
     test('generates proper csv URL', () => {
       const url = getExploreUrl({
@@ -87,10 +84,7 @@ describe('exploreUtils', () => {
         force: false,
         curUrl: 'superset.com',
       });
-      compareURI(
-        URI(url!),
-        URI('/superset/explore_json/').search({ csv: 'true' }),
-      );
+      compareURI(URI(url!), URI('/explore_json/').search({ csv: 'true' }));
     });
     test('generates proper standalone URL', () => {
       const url = getExploreUrl({
@@ -113,10 +107,7 @@ describe('exploreUtils', () => {
         force: false,
         curUrl: 'superset.com?foo=bar',
       });
-      compareURI(
-        URI(url!),
-        URI('/superset/explore_json/').search({ foo: 'bar' }),
-      );
+      compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' }));
     });
     test('generate proper save slice url', () => {
       const url = getExploreUrl({
@@ -125,10 +116,7 @@ describe('exploreUtils', () => {
         force: false,
         curUrl: 'superset.com?foo=bar',
       });
-      compareURI(
-        URI(url!),
-        URI('/superset/explore_json/').search({ foo: 'bar' }),
-      );
+      compareURI(URI(url!), URI('/explore_json/').search({ foo: 'bar' }));
     });
   });
 
diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
index 9ec504bdb4f..3311e92c761 100644
--- a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
@@ -201,9 +201,12 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL to onStartS
 
   expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
   const callArgs = onStartStreamingExport.mock.calls[0][0];
-  // The legacy blueprint path is /superset/explore_json/; with appRoot=/superset the
-  // full streaming URL is /superset/superset/explore_json/ (appRoot + blueprint prefix).
-  expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true');
+  // post `Superset.route_base = ""`, the blueprint path is
+  // `/explore_json/`. With appRoot=/superset, the prefixed URL is
+  // `/superset/explore_json/?csv=true` — single-prefix, not the legacy
+  // doubled `/superset/superset/explore_json/...` that this test used to
+  // pin (which was the bug, now fixed at source).
+  expect(callArgs.url).toBe('/superset/explore_json/?csv=true');
   expect(callArgs.exportType).toBe('csv');
 });
 
@@ -228,7 +231,7 @@ test('exportChart legacy API builds relative URL for CSV export without app root
 
   expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
   const callArgs = onStartStreamingExport.mock.calls[0][0];
-  expect(callArgs.url).toBe('/superset/explore_json/?csv=true');
+  expect(callArgs.url).toBe('/explore_json/?csv=true');
 });
 
 test('exportChart legacy API builds relative URL for xlsx export', async () => {
@@ -253,7 +256,7 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
 
   expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
   const callArgs = onStartStreamingExport.mock.calls[0][0];
-  expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
+  expect(callArgs.url).toBe('/explore_json/?xlsx=true');
 });
 
 test('exportChart legacy API calls postBlob with relative URL', async () => {
@@ -278,7 +281,7 @@ test('exportChart legacy API calls postBlob with relative URL', async () => {
 
   expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
   const [url] = SupersetClient.postBlob.mock.calls[0];
-  expect(url).toBe('/superset/explore_json/?csv=true');
+  expect(url).toBe('/explore_json/?csv=true');
   expect(url).not.toMatch(/^https?:\/\//);
   expect(downloadBlob).toHaveBeenCalled();
 });
@@ -305,7 +308,7 @@ test('exportChart legacy API includes force param when force=true', async () =>
 
   expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
   const callArgs = onStartStreamingExport.mock.calls[0][0];
-  expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
+  expect(callArgs.url).toBe('/explore_json/?force=true&csv=true');
 });
 
 test('exportChart successfully exports chart as CSV', async () => {
diff --git a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
index 032133545e8..782d4412863 100644
--- a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
@@ -39,7 +39,7 @@ test('Get ExploreUrl with default params', () => {
 test('Get ExploreUrl with endpointType:full', () => {
   const params = createParams();
   expect(getExploreUrl({ ...params, endpointType: 'full' })).toBe(
-    'http://localhost/superset/explore_json/',
+    'http://localhost/explore_json/',
   );
 });
 
@@ -47,21 +47,21 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => {
   const params = createParams();
   expect(
     getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }),
-  ).toBe('http://localhost/superset/explore_json/');
+  ).toBe('http://localhost/explore_json/');
 });
 
 test('Get relative ExploreUrl with endpointType:csv', () => {
   const params = createParams();
   expect(
     getExploreUrl({ ...params, endpointType: 'csv', relative: true }),
-  ).toBe('/superset/explore_json/?csv=true');
+  ).toBe('/explore_json/?csv=true');
 });
 
 test('Get relative ExploreUrl with endpointType:xlsx', () => {
   const params = createParams();
   expect(
     getExploreUrl({ ...params, endpointType: 'xlsx', relative: true }),
-  ).toBe('/superset/explore_json/?xlsx=true');
+  ).toBe('/explore_json/?xlsx=true');
 });
 
 test('Get relative ExploreUrl with force:true', () => {
@@ -73,7 +73,7 @@ test('Get relative ExploreUrl with force:true', () => {
       force: true,
       relative: true,
     }),
-  ).toBe('/superset/explore_json/?force=true&csv=true');
+  ).toBe('/explore_json/?force=true&csv=true');
 });
 
 test('Get relative ExploreUrl with endpointType:base', () => {
diff --git a/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts b/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts
index 1d6ad40045a..7d3b004cd4d 100644
--- a/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/getURIDirectory.test.ts
@@ -19,8 +19,12 @@
 import { getURIDirectory } from '.';
 
 test('Cases in which the "explore_json" will be returned', () => {
+  // post `Superset.route_base = ""` collapse the legacy
+  // `/superset/explore_json/` endpoint is gone; `getURIDirectory` now
+  // returns the bare `/explore_json/` path (appRoot is applied at the
+  // outer `ensureAppRoot` layer when `includeAppRoot=true`).
   ['full', 'json', 'csv', 'query', 'results', 'samples'].forEach(name => {
-    expect(getURIDirectory(name)).toBe('/superset/explore_json/');
+    expect(getURIDirectory(name)).toBe('/explore_json/');
   });
 });
 
diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts
index 10c70165314..3e1cd406fbe 100644
--- a/superset-frontend/src/explore/exploreUtils/index.ts
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -33,7 +33,7 @@ import {
 import { availableDomains } from 'src/utils/hostNamesConfig';
 import { safeStringify } from 'src/utils/safeStringify';
 import { optionLabel } from 'src/utils/common';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
 import { URL_PARAMS } from 'src/constants';
 import {
@@ -166,7 +166,7 @@ export function getURIDirectory(
     'results',
     'samples',
   ].includes(endpointType)
-    ? '/superset/explore_json/'
+    ? '/explore_json/'
     : '/explore/';
   return includeAppRoot ? ensureAppRoot(uri) : uri;
 }
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts
index d2debd6d69c..a02e2515e33 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts
@@ -23,6 +23,19 @@ import ExtensionsLoader from './ExtensionsLoader';
 
 type Extension = core.Extension;
 
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    ...actual,
+    applicationRoot: () => mockApplicationRoot(),
+  };
+});
+
 function createMockExtension(overrides: Partial<Extension> = {}): Extension {
   return {
     id: 'test-extension',
@@ -37,8 +50,39 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
 
 beforeEach(() => {
   (ExtensionsLoader as any).instance = undefined;
+  mockApplicationRoot.mockReturnValue('');
 });
 
+/**
+ * Capture the src attribute of the remote-entry script element and trigger
+ * its onerror handler so loadModule short-circuits without webpack module
+ * federation globals.
+ */
+function captureRemoteEntryScript(): {
+  getSrc: () => string | null;
+  restore: () => void;
+} {
+  let capturedSrc: string | null = null;
+  const appendChildSpy = jest
+    .spyOn(document.head, 'appendChild')
+    .mockImplementation((element: Node) => {
+      if (element instanceof HTMLScriptElement) {
+        capturedSrc = element.getAttribute('src');
+        if (element.onerror) {
+          const errorHandler = element.onerror;
+          setTimeout(() => {
+            errorHandler('Script load halted by test');
+          }, 0);
+        }
+      }
+      return element;
+    });
+  return {
+    getSrc: () => capturedSrc,
+    restore: () => appendChildSpy.mockRestore(),
+  };
+}
+
 test('creates a singleton instance', () => {
   const instance1 = ExtensionsLoader.getInstance();
   const instance2 = ExtensionsLoader.getInstance();
@@ -126,6 +170,70 @@ test('logs success after initializeExtensions completes', async () => {
   infoSpy.mockRestore();
 });
 
+// Subdirectory regression (gap review 2026-06-10): the backend emits a
+// router-relative remoteEntry URL; assigning it raw to `script.src` resolved
+// it against the domain root, 404ing every extension under a subdirectory
+// deployment.
+test('prefixes a router-relative remoteEntry with the application root', async () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const loader = ExtensionsLoader.getInstance();
+  const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
+  const script = captureRemoteEntryScript();
+
+  await loader.initializeExtension(
+    createMockExtension({
+      id: 'sub-ext',
+      remoteEntry: '/api/v1/extensions/pub/sub-ext/remoteEntry.js',
+    }),
+  );
+
+  expect(script.getSrc()).toBe(
+    '/superset/api/v1/extensions/pub/sub-ext/remoteEntry.js',
+  );
+
+  errorSpy.mockRestore();
+  script.restore();
+});
+
+test('leaves the remoteEntry unprefixed on root deployments', async () => {
+  const loader = ExtensionsLoader.getInstance();
+  const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
+  const script = captureRemoteEntryScript();
+
+  await loader.initializeExtension(
+    createMockExtension({
+      id: 'root-ext',
+      remoteEntry: '/api/v1/extensions/pub/root-ext/remoteEntry.js',
+    }),
+  );
+
+  expect(script.getSrc()).toBe(
+    '/api/v1/extensions/pub/root-ext/remoteEntry.js',
+  );
+
+  errorSpy.mockRestore();
+  script.restore();
+});
+
+test('passes an absolute remoteEntry URL through unchanged', async () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const loader = ExtensionsLoader.getInstance();
+  const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
+  const script = captureRemoteEntryScript();
+
+  await loader.initializeExtension(
+    createMockExtension({
+      id: 'cdn-ext',
+      remoteEntry: 'https://cdn.example.com/remoteEntry.js',
+    }),
+  );
+
+  expect(script.getSrc()).toBe('https://cdn.example.com/remoteEntry.js');
+
+  errorSpy.mockRestore();
+  script.restore();
+});
+
 test('logs error when initializeExtensions fails', async () => {
   const loader = ExtensionsLoader.getInstance();
   const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts
index 0b74ef9be86..f4abaf5fdbb 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.ts
@@ -19,6 +19,7 @@
 import { SupersetClient } from '@superset-ui/core';
 import { logging } from '@apache-superset/core/utils';
 import type { common as core } from '@apache-superset/core';
+import { makeUrl } from 'src/utils/navigationUtils';
 
 type Extension = core.Extension;
 
@@ -107,10 +108,12 @@ class ExtensionsLoader {
   private async loadModule(extension: Extension): Promise<void> {
     const { remoteEntry, id } = extension;
 
-    // Load the remote entry script
+    // Load the remote entry script. The backend emits a router-relative
+    // remoteEntry URL; `makeUrl` applies the application root for
+    // subdirectory deployments (idempotent, and absolute URLs pass through).
     await new Promise<void>((resolve, reject) => {
       const element = document.createElement('script');
-      element.src = remoteEntry;
+      element.src = makeUrl(remoteEntry);
       element.type = 'text/javascript';
       element.async = true;
       element.onload = () => resolve();
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index df18f3a0e8e..67cf34e1b98 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -1427,7 +1427,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
 
   const openDashboardInNewTab = (dashboardId?: number | string | null) => {
     if (!dashboardId) return;
-    navigateTo(`/superset/dashboard/${dashboardId}`, { newWindow: true });
+    navigateTo(`/dashboard/${dashboardId}/`, { newWindow: true });
   };
 
   const onChartChange = (chart: SelectValue) => {
diff --git a/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx b/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx
new file mode 100644
index 00000000000..8573e0b52fb
--- /dev/null
+++ b/superset-frontend/src/features/allEntities/AllEntitiesTable.subdirectory.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render } from 'spec/helpers/testing-library';
+import type { TaggedObjects } from 'src/types/TaggedObject';
+import AllEntitiesTable from './AllEntitiesTable';
+
+// Regression for the tag list page (sc-108439): backend `.url` properties on
+// Dashboard / Slice / SavedQuery are router-relative by contract
+// (see `superset/models/dashboard.py` `dashboard_link` docstring: "`Dashboard.url`
+// itself stays router-relative so frontend callers can apply ensureAppRoot
+// exactly once"). The TagDAO emits those router-relative paths verbatim as
+// `o.url` on tagged-object responses. AllEntitiesTable therefore must wrap
+// `o.url` with `ensureAppRoot` exactly once; otherwise the row hrefs lack
+// the `SUPERSET_APP_ROOT` prefix and clicks land on broken links.
+//
+// Mocking note (same as SliceHeaderControls.subdirectory.test.tsx): the
+// component is imported statically, so the `withApplicationRoot` fixture's
+// `jest.resetModules()` + dynamic re-import pattern can't retroactively
+// change which `getBootstrapData` instance the already-bound module graph
+// sees. We mock `src/utils/getBootstrapData` directly with a reconfigurable
+// `mockApplicationRoot` factory and flip it per scenario.
+//
+// Name must start with `mock` so Jest's hoisted jest.mock() factory may
+// reference it. `default` returns a STATIC shape (not mockApplicationRoot)
+// because consumers like the reducer chain call getBootstrapData() at
+// import time — calling mockApplicationRoot inside `default` hits TDZ.
+
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: () => ({
+    common: { application_root: '', static_assets_prefix: '' },
+  }),
+  applicationRoot: () => mockApplicationRoot(),
+  staticAssetsPrefix: () => '',
+}));
+
+const BACKEND_URLS = {
+  dashboard: '/dashboard/sales/',
+  chart: '/explore/?slice_id=42',
+  query: '/sqllab?savedQueryId=7',
+};
+
+const SAMPLE_OBJECTS: TaggedObjects = {
+  dashboard: [
+    {
+      id: 1,
+      type: 'dashboard',
+      name: 'Sales',
+      url: BACKEND_URLS.dashboard,
+      changed_on: '2026-06-01T00:00:00Z',
+      created_by: 1,
+      creator: 'admin',
+      owners: [],
+      tags: [],
+    },
+  ],
+  chart: [
+    {
+      id: 42,
+      type: 'chart',
+      name: 'Top Customers',
+      url: BACKEND_URLS.chart,
+      changed_on: '2026-06-01T00:00:00Z',
+      created_by: 1,
+      creator: 'admin',
+      owners: [],
+      tags: [],
+    },
+  ],
+  query: [
+    {
+      id: 7,
+      type: 'query',
+      name: 'Daily Revenue',
+      url: BACKEND_URLS.query,
+      changed_on: '2026-06-01T00:00:00Z',
+      created_by: 1,
+      creator: 'admin',
+      owners: [],
+      tags: [],
+    },
+  ],
+};
+
+const renderAndCollectHrefs = (): string[] => {
+  const { container } = render(
+    <AllEntitiesTable
+      objects={SAMPLE_OBJECTS}
+      setShowTagModal={() => {}}
+      canEditTag
+    />,
+    { useRedux: true, useTheme: true },
+  );
+  return Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href]'))
+    .map(a => a.getAttribute('href') ?? '')
+    .filter(href => href !== '' && href !== '#');
+};
+
+beforeEach(() => {
+  mockApplicationRoot.mockReset();
+});
+
+test('row hrefs carry the application root under /superset', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const hrefs = renderAndCollectHrefs();
+
+  expect(hrefs).toEqual(
+    expect.arrayContaining([
+      `/superset${BACKEND_URLS.dashboard}`,
+      `/superset${BACKEND_URLS.chart}`,
+      `/superset${BACKEND_URLS.query}`,
+    ]),
+  );
+  hrefs.forEach(href => {
+    expect(href).not.toMatch(/^\/superset\/superset\//);
+    expect(href.startsWith('/superset')).toBe(true);
+  });
+});
+
+test('row hrefs are unchanged under the default root-of-domain deployment', () => {
+  mockApplicationRoot.mockReturnValue('');
+  const hrefs = renderAndCollectHrefs();
+
+  expect(hrefs).toEqual(
+    expect.arrayContaining([
+      BACKEND_URLS.dashboard,
+      BACKEND_URLS.chart,
+      BACKEND_URLS.query,
+    ]),
+  );
+  hrefs.forEach(href => {
+    expect(href).not.toMatch(/^\/superset/);
+  });
+});
diff --git a/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx b/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx
index 78a9f146c2f..2e02e2379f5 100644
--- a/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx
+++ b/superset-frontend/src/features/allEntities/AllEntitiesTable.tsx
@@ -27,6 +27,7 @@ import { EmptyState } from '@superset-ui/core/components';
 import { FacePile, TagsList, type TagType } from 'src/components';
 import { TaggedObject, TaggedObjects } from 'src/types/TaggedObject';
 import { Typography } from '@superset-ui/core/components/Typography';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 
 const MAX_TAGS_TO_SHOW = 3;
 const PAGE_SIZE = 10;
@@ -73,7 +74,9 @@ export default function AllEntitiesTable({
 
   const renderTable = (type: objectType) => {
     const data = objects[type].map((o: TaggedObject) => ({
-      [type]: <Typography.Link href={o.url}>{o.name}</Typography.Link>,
+      [type]: (
+        <Typography.Link href={ensureAppRoot(o.url)}>{o.name}</Typography.Link>
+      ),
       modified: o.changed_on ? extendedDayjs.utc(o.changed_on).fromNow() : '',
       tags: o.tags,
       owners: o.owners,
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseModal.subdirectory.test.ts b/superset-frontend/src/features/databases/DatabaseModal/DatabaseModal.subdirectory.test.ts
new file mode 100644
index 00000000000..886cc9f861b
--- /dev/null
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseModal.subdirectory.test.ts
@@ -0,0 +1,160 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Subdirectory regression coverage for the DatabaseModal "post-connection"
+// call-to-action buttons (Create dataset + Query data in SQL Lab).
+//
+// Original bug class: under a subdir deployment (`applicationRoot = '/superset'`)
+// the "Query data in SQL Lab" button navigated to a double-prefixed URL
+// (`/superset/superset/sqllab?db=true`). The fix routes both CTA buttons
+// through `redirectURL`, which delegates to `useHistory().push(...)`. React
+// Router's `BrowserRouter basename={applicationRoot()}` re-applies the prefix
+// internally, so the argument to `history.push` MUST be a router-relative
+// path (no leading `${applicationRoot}` and no `ensureAppRoot` wrap).
+//
+// Reaching `renderCTABtns()` through a rendered modal requires walking the
+// full "select engine → fill SQLAlchemy form → submit → wait for success"
+// flow with every fetch mocked. The contract under test is much smaller
+// than that surface, so this file uses the same source-pin + pure-logic
+// characterisation pattern as
+// `dashboard/components/nativeFilters/FilterBar/FilterBar.subdirectory.test.ts`.
+
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+const MODAL_SRC = readFileSync(join(__dirname, 'index.tsx'), 'utf8');
+
+// ---------------------------------------------------------------------------
+// Source-pin: redirectURL receives router-relative paths.
+// ---------------------------------------------------------------------------
+
+test('DatabaseModal redirectURL delegates to history.push', () => {
+  // The redirectURL helper is the single funnel for post-connection
+  // navigation. Both CTA buttons call it; the only safe argument shape is
+  // a router-relative path, because BrowserRouter's basename re-applies the
+  // app root. Pinning the helper body here means a future refactor that
+  // re-introduces `applicationRoot()` or `ensureAppRoot` at this layer
+  // fails loudly with the exact callsite.
+  expect(MODAL_SRC).toMatch(
+    /const redirectURL = \(url: string\) => \{\s*history\.push\(url\);\s*\};/,
+  );
+});
+
+test('DatabaseModal "Query data in SQL Lab" pushes a router-relative /sqllab', () => {
+  // The exact string we want in source. If someone "fixes" subdir support
+  // by wrapping this in ensureAppRoot or hard-coding `/superset/sqllab`,
+  // this test fires before the bad change ships.
+  expect(MODAL_SRC).toContain("redirectURL('/sqllab?db=true')");
+});
+
+test('DatabaseModal "Create dataset" pushes a router-relative /dataset/add/', () => {
+  // Symmetric invariant for the sibling CTA button — same risk class
+  // (basename double-prefix) applies. Pinning prevents drift where someone
+  // "fixes" one button and leaves the other inconsistent.
+  expect(MODAL_SRC).toContain("redirectURL('/dataset/add/')");
+});
+
+test('DatabaseModal CTA buttons do NOT prefix the app root themselves', () => {
+  // The buttons must not call applicationRoot()/ensureAppRoot/makeUrl on
+  // these specific paths — basename handles the prefix, and any extra
+  // prefixing produces `/superset/superset/...`. Search the renderCTABtns
+  // block (lines ~1855-1879 at time of writing) for offending patterns.
+  const ctaMatch = MODAL_SRC.match(
+    /const renderCTABtns = \(\) =>[\s\S]*?<\/StyledBtns>\s*\);/,
+  );
+  expect(ctaMatch).not.toBeNull();
+  const ctaSrc = ctaMatch![0];
+  expect(ctaSrc).not.toMatch(/applicationRoot\s*\(/);
+  expect(ctaSrc).not.toMatch(/ensureAppRoot\s*\(/);
+  expect(ctaSrc).not.toMatch(/makeUrl\s*\(/);
+  // And the specific double-prefixed string the original bug produced —
+  // pin it so a regression that hard-codes the app root never ships.
+  expect(ctaSrc).not.toContain('/superset/sqllab');
+  expect(ctaSrc).not.toContain('/superset/dataset/add');
+});
+
+// ---------------------------------------------------------------------------
+// Characterisation: the documented invariant exercised across app-roots.
+// ---------------------------------------------------------------------------
+//
+// Re-implements the behaviour the source-pin describes: a router-relative
+// path pushed through history under BrowserRouter's basename produces a
+// single-prefixed URL. If the documented invariant itself is wrong the
+// source-pin is useless; this matrix catches that.
+
+interface Scenario {
+  description: string;
+  basename: string;
+  pushArg: string;
+  expected: string;
+}
+
+// Mirror of how React Router composes basename + pushed path. React Router
+// requires the pushed path to start with '/' and concatenates basename in
+// front (stripping a trailing slash on basename if present).
+function composeUnderBasename(basename: string, pushArg: string): string {
+  const normalisedBase =
+    basename === '/' || basename === '' ? '' : basename.replace(/\/+$/, '');
+  return `${normalisedBase}${pushArg}`;
+}
+
+const SCENARIOS: ReadonlyArray<Scenario> = [
+  {
+    description: 'root deploy: bare basename + /sqllab?db=true',
+    basename: '',
+    pushArg: '/sqllab?db=true',
+    expected: '/sqllab?db=true',
+  },
+  {
+    description: 'subdir deploy: /superset + /sqllab?db=true',
+    basename: '/superset',
+    pushArg: '/sqllab?db=true',
+    expected: '/superset/sqllab?db=true',
+  },
+  {
+    description: 'subdir deploy: /superset + /dataset/add/',
+    basename: '/superset',
+    pushArg: '/dataset/add/',
+    expected: '/superset/dataset/add/',
+  },
+  {
+    description: 'deep-nested deploy: /tenant-a/superset + /sqllab?db=true',
+    basename: '/tenant-a/superset',
+    pushArg: '/sqllab?db=true',
+    expected: '/tenant-a/superset/sqllab?db=true',
+  },
+  {
+    description: 'subdir deploy: trailing slash on basename collapses cleanly',
+    basename: '/superset/',
+    pushArg: '/sqllab?db=true',
+    expected: '/superset/sqllab?db=true',
+  },
+];
+
+test.each(SCENARIOS)(
+  'redirectURL navigation: $description',
+  ({ basename, pushArg, expected }: Scenario) => {
+    const url = composeUnderBasename(basename, pushArg);
+    expect(url).toBe(expected);
+    // The dedupe contract: no `/superset/superset/...` ever reaches the
+    // browser. Even if the source-pin drifts, this catches the user-visible
+    // symptom for the subdir case.
+    expect(url).not.toMatch(/\/superset\/superset\//);
+  },
+);
diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx
new file mode 100644
index 00000000000..0215158a61e
--- /dev/null
+++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx
@@ -0,0 +1,114 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import userEvent from '@testing-library/user-event';
+import { render, screen } from 'spec/helpers/testing-library';
+
+// DatasetPanel opens the dataset via openInNewTab -> ensureAppRoot, which reads
+// applicationRoot() statically. Intercept it to simulate a subdirectory deploy.
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    default: actual.default,
+    applicationRoot: () => mockApplicationRoot(),
+    staticAssetsPrefix: actual.staticAssetsPrefix,
+  };
+});
+
+// eslint-disable-next-line import/first
+import DatasetPanel from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
+// eslint-disable-next-line import/first
+import { exampleColumns } from './fixtures';
+// eslint-disable-next-line import/first
+import { DatasetObject } from 'src/features/datasets/AddDataset/types';
+
+const APP_ROOT = '/superset';
+
+let openSpy: jest.SpyInstance;
+
+beforeEach(() => {
+  mockApplicationRoot.mockReturnValue(APP_ROOT);
+  openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+});
+
+afterEach(() => {
+  jest.restoreAllMocks();
+});
+
+const datasetWith = (exploreUrl: string): DatasetObject[] => [
+  {
+    db: {
+      id: 1,
+      database_name: 'test_database',
+      owners: [1],
+      backend: 'test_backend',
+    },
+    schema: 'test_schema',
+    dataset_name: 'example_dataset',
+    table_name: 'example_table',
+    explore_url: exploreUrl,
+  },
+];
+
+test('View Dataset opens a single-prefixed URL under a subdirectory deployment', async () => {
+  // The backend emits explore_url already carrying the application root. The
+  // strip must run before ensureAppRoot re-prefixes, otherwise the opened tab
+  // would point at a doubled /superset/superset/... path.
+  render(
+    <DatasetPanel
+      tableName="example_table"
+      hasError={false}
+      columnList={exampleColumns}
+      loading={false}
+      datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
+    />,
+    { useRouter: true },
+  );
+
+  await userEvent.click(screen.getByText('View Dataset'));
+
+  expect(openSpy).toHaveBeenCalledTimes(1);
+  const openedUrl = openSpy.mock.calls[0][0];
+  expect(openedUrl).toBe(`${APP_ROOT}/explore/?datasource=1__table`);
+  expect(openedUrl).not.toContain('/superset/superset');
+});
+
+test('View Dataset passes an external explore_url through unprefixed', async () => {
+  render(
+    <DatasetPanel
+      tableName="example_table"
+      hasError={false}
+      columnList={exampleColumns}
+      loading={false}
+      datasets={datasetWith('https://external.example.com/custom-endpoint')}
+    />,
+    { useRouter: true },
+  );
+
+  await userEvent.click(screen.getByText('View Dataset'));
+
+  expect(openSpy).toHaveBeenCalledTimes(1);
+  expect(openSpy.mock.calls[0][0]).toBe(
+    'https://external.example.com/custom-endpoint',
+  );
+});
diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx
index e18cd00c6fa..6bf357a1f3b 100644
--- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx
+++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.tsx
@@ -26,6 +26,7 @@ import Table, {
   TableSize,
 } from '@superset-ui/core/components/Table';
 import { DatasetObject } from 'src/features/datasets/AddDataset/types';
+import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
 import { ITableColumn } from './types';
 import MessageContent from './MessageContent';
 
@@ -227,11 +228,12 @@ const renderExistingDatasetAlert = (dataset?: DatasetObject) => (
         <span
           role="button"
           onClick={() => {
-            window.open(
-              dataset?.explore_url,
-              '_blank',
-              'noreferrer noopener popup=false',
-            );
+            if (dataset?.explore_url) {
+              // `explore_url` is router-relative from the backend (rooted under
+              // a subdirectory deployment); strip the root so openInNewTab's
+              // ensureAppRoot re-prefixes it once rather than doubling it.
+              openInNewTab(stripAppRoot(dataset.explore_url));
+            }
           }}
           tabIndex={0}
           className="view-dataset-button"
diff --git a/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx b/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx
index c23017aeed7..ef90f1815eb 100644
--- a/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx
+++ b/superset-frontend/src/features/datasets/AddDataset/LeftPanel/index.tsx
@@ -29,8 +29,8 @@ import {
   DatasetObject,
 } from 'src/features/datasets/AddDataset/types';
 import { Table } from 'src/hooks/apiResources';
-import { ensureAppRoot } from 'src/utils/pathUtils';
 import { Typography } from '@superset-ui/core/components/Typography';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 
 interface LeftPanelProps {
   setDataset: Dispatch<SetStateAction<object>>;
diff --git a/superset-frontend/src/features/home/Menu.subdirectory.test.tsx b/superset-frontend/src/features/home/Menu.subdirectory.test.tsx
new file mode 100644
index 00000000000..4d1083fad2f
--- /dev/null
+++ b/superset-frontend/src/features/home/Menu.subdirectory.test.tsx
@@ -0,0 +1,230 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import * as reactRedux from 'react-redux';
+import fetchMock from 'fetch-mock';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryParamProvider } from 'use-query-params';
+import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
+import { render, screen } from 'spec/helpers/testing-library';
+import * as CoreTheme from '@apache-superset/core/theme';
+
+// Menu's brand link statically reads applicationRoot(); intercept it to
+// simulate a subdirectory deployment.
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    default: actual.default,
+    applicationRoot: () => mockApplicationRoot(),
+    staticAssetsPrefix: actual.staticAssetsPrefix,
+  };
+});
+
+jest.mock('@apache-superset/core/theme', () => ({
+  ...jest.requireActual('@apache-superset/core/theme'),
+  useTheme: jest.fn(),
+}));
+
+jest.mock('antd', () => ({
+  ...jest.requireActual('antd'),
+  Grid: {
+    ...jest.requireActual('antd').Grid,
+    useBreakpoint: () => ({ md: true }),
+  },
+}));
+
+// IMPORTANT: unlike Menu.test.tsx this file does NOT mock GenericLink. It
+// renders the real react-router-dom <Link> under a real
+// `<BrowserRouter basename={APP_ROOT}>` — the same seam production builds via
+// `<Router basename={applicationRoot()}>` in src/views/App.tsx. BrowserRouter
+// honours basename in createHref and re-prepends the root WITHOUT deduping, so
+// this exercises the actual rendered href the user clicks. The seam mock in
+// Menu.test.tsx (no basename) is blind to that re-prepend; this file covers it.
+
+// eslint-disable-next-line import/first
+import { Menu } from './Menu';
+
+const APP_ROOT = '/superset';
+
+const user = {
+  createdOn: '2021-04-27T18:12:38.952304',
+  email: 'admin',
+  firstName: 'admin',
+  isActive: true,
+  lastName: 'admin',
+  permissions: {},
+  roles: {
+    Admin: [
+      ['can_sqllab', 'Superset'],
+      ['can_write', 'Dashboard'],
+      ['can_write', 'Chart'],
+    ],
+  },
+  userId: 1,
+  username: 'admin',
+};
+
+const mockedProps = {
+  user,
+  data: {
+    menu: [],
+    brand: {
+      path: '/superset/welcome/',
+      icon: '/static/assets/images/superset-logo-horiz.png',
+      alt: 'Apache Superset',
+      width: '126',
+      tooltip: '',
+      text: '',
+    },
+    environment_tag: {
+      text: 'Production',
+      color: '#000',
+    },
+    navbar_right: {
+      show_watermark: false,
+      bug_report_url: '/report/',
+      documentation_url: '/docs/',
+      languages: {
+        en: { flag: 'us', name: 'English', url: '/lang/en' },
+      },
+      show_language_picker: true,
+      user_is_anonymous: true,
+      user_info_url: '/users/userinfo/',
+      user_logout_url: '/logout/',
+      user_login_url: '/login/',
+      locale: 'en',
+      version_string: '1.0.0',
+      version_sha: 'randomSHA',
+      build_number: 'randomBuildNumber',
+    },
+    settings: [],
+  },
+};
+
+const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
+const useThemeMock = CoreTheme.useTheme as jest.Mock;
+
+fetchMock.get(
+  'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))',
+  {},
+);
+
+const renderUnderSubdirectory = () =>
+  render(
+    // Mirror production's `<Router basename={applicationRoot()}>`
+    // (views/App.tsx). BrowserRouter honours basename in createHref, so a
+    // router-relative `to` is re-prefixed exactly once — the seam the brand
+    // link's strip must feed correctly.
+    <BrowserRouter basename={APP_ROOT}>
+      <QueryParamProvider adapter={ReactRouter5Adapter}>
+        <Menu {...mockedProps} />
+      </QueryParamProvider>
+    </BrowserRouter>,
+    { useRedux: true, useTheme: true },
+  );
+
+beforeEach(() => {
+  mockApplicationRoot.mockReturnValue(APP_ROOT);
+  useSelectorMock.mockReturnValue({ roles: user.roles });
+  // Keep the page URL under the basename so BrowserRouter is consistent.
+  window.history.replaceState({}, '', `${APP_ROOT}/welcome/`);
+});
+
+afterEach(() => {
+  window.history.replaceState({}, '', '/');
+  useSelectorMock.mockClear();
+  jest.restoreAllMocks();
+});
+
+test('brand logo link is single-prefixed when brandLogoHref arrives already rooted', async () => {
+  // The backend emits brandLogoHref already carrying the app root. The Router
+  // basename re-prepends the root, so the strip must run first to avoid a
+  // doubled /superset/superset/... href in the rendered anchor.
+  useThemeMock.mockReturnValue({
+    ...CoreTheme.supersetTheme,
+    brandLogoUrl: '/static/assets/images/custom-logo.png',
+    brandLogoHref: '/superset/welcome/',
+  });
+
+  renderUnderSubdirectory();
+
+  const brandLink = await screen.findByRole('link', {
+    name: /apache superset/i,
+  });
+  expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
+  expect(brandLink.getAttribute('href')).not.toContain('/superset/superset');
+});
+
+test('brand logo link is single-prefixed when brandLogoHref is a bare path', async () => {
+  // A bare (un-rooted) brandLogoHref must end up prefixed exactly once after
+  // the Router basename re-prepend.
+  useThemeMock.mockReturnValue({
+    ...CoreTheme.supersetTheme,
+    brandLogoUrl: '/static/assets/images/custom-logo.png',
+    brandLogoHref: '/welcome/',
+  });
+
+  renderUnderSubdirectory();
+
+  const brandLink = await screen.findByRole('link', {
+    name: /apache superset/i,
+  });
+  expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
+  expect(brandLink.getAttribute('href')).not.toContain('/superset/superset');
+});
+
+test('brand logo link renders without crashing when brandLogoHref is unset (partial theme override)', async () => {
+  // A partial theme override can set brandLogoUrl (the image) while leaving
+  // brandLogoHref undefined. The internal branch must stay null-safe: stripping
+  // the ensureAppRoot'd value (which falls back to the app root for an undefined
+  // input) avoids a `undefined.startsWith` TypeError that would white-screen the
+  // nav chrome. The rendered href resolves to the app root, never doubled.
+  useThemeMock.mockReturnValue({
+    ...CoreTheme.supersetTheme,
+    brandLogoUrl: '/static/assets/images/custom-logo.png',
+    brandLogoHref: undefined,
+  });
+
+  renderUnderSubdirectory();
+
+  const brandLink = await screen.findByRole('link', {
+    name: /apache superset/i,
+  });
+  expect(brandLink.getAttribute('href')).toMatch(/^\/superset\/?$/);
+  expect(brandLink.getAttribute('href')).not.toContain('/superset/superset');
+});
+
+test('external brandLogoHref passes through without app-root prefixing', async () => {
+  useThemeMock.mockReturnValue({
+    ...CoreTheme.supersetTheme,
+    brandLogoUrl: '/static/assets/images/custom-logo.png',
+    brandLogoHref: 'https://external.example.com',
+  });
+
+  renderUnderSubdirectory();
+
+  const brandLink = await screen.findByRole('link', {
+    name: /apache superset/i,
+  });
+  expect(brandLink).toHaveAttribute('href', 'https://external.example.com');
+});
diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx
index 99555d14296..08f1db0f5ae 100644
--- a/superset-frontend/src/features/home/Menu.test.tsx
+++ b/superset-frontend/src/features/home/Menu.test.tsx
@@ -25,6 +25,35 @@ import * as CoreTheme from '@apache-superset/core/theme';
 import { Menu } from './Menu';
 import * as getBootstrapData from 'src/utils/getBootstrapData';
 
+// Capture what `<GenericLink to={...}>` receives so the SPA-route regression
+// tests can assert on the value handed to react-router-dom (which applies its
+// own basename in production via `<Router basename={applicationRoot()}>` in
+// src/views/App.tsx). The test harness's `<BrowserRouter>` has no basename,
+// so asserting on the rendered `<a href>` wouldn't catch the double-prefix.
+let observedGenericLinkTo: unknown = null;
+jest.mock('src/components/GenericLink', () => ({
+  __esModule: true,
+  GenericLink: ({
+    to,
+    children,
+    ...rest
+  }: {
+    to: unknown;
+    children: React.ReactNode;
+    [k: string]: unknown;
+  }) => {
+    observedGenericLinkTo = to;
+    return (
+      <a
+        href={typeof to === 'string' ? to : '#'}
+        {...(rest as Record<string, unknown>)}
+      >
+        {children}
+      </a>
+    );
+  },
+}));
+
 jest.mock('@apache-superset/core/theme', () => ({
   ...jest.requireActual('@apache-superset/core/theme'),
   useTheme: jest.fn(),
@@ -797,6 +826,161 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as
   expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
 });
 
+// Regression: the real backend emits `brand.path` and `brand.icon` already
+// carrying the app root (because they pass through `url_for`). The frontend
+// must not double-prefix them — neither via
+// `ensureAppRoot`/`ensureStaticPrefix` nor via React Router's `basename`
+// re-prepend.
+//
+// In production the SPA-route branch goes through `<GenericLink to={...}> ->
+// react-router-dom <Link>`, and the Router's `basename={applicationRoot()}`
+// (src/views/App.tsx) re-prepends the app root to the rendered `href`. The
+// test harness's `<BrowserRouter>` has no basename, so asserting on the
+// rendered `<a href>` wouldn't catch the bug. Instead we mock `GenericLink`
+// at module load (top of this file) and assert the path *handed to it*
+// already has the root stripped — the value the production Router will then
+// safely re-prepend.
+
+describe('brand link single-prefix regressions (subdirectory deployment)', () => {
+  beforeEach(() => {
+    observedGenericLinkTo = null;
+  });
+
+  test('brand link hands a root-stripped path to GenericLink when brand.path arrives already rooted (SPA route)', async () => {
+    applicationRootMock.mockReturnValue('/superset');
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    useSelectorMock.mockReturnValue({ roles: user.roles });
+
+    const propsWithRootedBrand = {
+      ...mockedProps,
+      isFrontendRoute: () => true,
+      data: {
+        ...mockedProps.data,
+        brand: {
+          ...mockedProps.data.brand,
+          path: '/superset/welcome/',
+          icon: '/superset/static/assets/images/superset-logo-horiz.png',
+        },
+      },
+    };
+
+    render(<Menu {...propsWithRootedBrand} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    // Wait for the mocked GenericLink to render.
+    await screen.findByRole('link', {
+      name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
+    });
+    expect(observedGenericLinkTo).toBe('/welcome/');
+  });
+
+  test('brand link is single-prefix when brand.path arrives already rooted (non-SPA route)', async () => {
+    applicationRootMock.mockReturnValue('/superset');
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    useSelectorMock.mockReturnValue({ roles: user.roles });
+
+    const propsWithRootedBrand = {
+      ...mockedProps,
+      isFrontendRoute: () => false,
+      data: {
+        ...mockedProps.data,
+        brand: {
+          ...mockedProps.data.brand,
+          path: '/superset/welcome/',
+          icon: '/superset/static/assets/images/superset-logo-horiz.png',
+        },
+      },
+    };
+
+    render(<Menu {...propsWithRootedBrand} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    const brandLink = await screen.findByRole('link', {
+      name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
+    });
+    expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
+    const brandImg = brandLink.querySelector('img');
+    expect(brandImg).toHaveAttribute(
+      'src',
+      '/superset/static/assets/images/superset-logo-horiz.png',
+    );
+  });
+
+  test('brand link strips a nested application root before handing to GenericLink', async () => {
+    applicationRootMock.mockReturnValue('/preset/superset');
+    staticAssetsPrefixMock.mockReturnValue('/preset/superset');
+    useSelectorMock.mockReturnValue({ roles: user.roles });
+
+    const propsWithRootedBrand = {
+      ...mockedProps,
+      isFrontendRoute: () => true,
+      data: {
+        ...mockedProps.data,
+        brand: {
+          ...mockedProps.data.brand,
+          path: '/preset/superset/welcome/',
+          icon: '/preset/superset/static/assets/images/superset-logo-horiz.png',
+        },
+      },
+    };
+
+    render(<Menu {...propsWithRootedBrand} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    await screen.findByRole('link', {
+      name: new RegExp(propsWithRootedBrand.data.brand.alt, 'i'),
+    });
+    expect(observedGenericLinkTo).toBe('/welcome/');
+  });
+
+  test('brand link from theme.brandLogoHref hands a root-stripped path to GenericLink when already rooted', async () => {
+    applicationRootMock.mockReturnValue('/superset');
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    useSelectorMock.mockReturnValue({ roles: user.roles });
+
+    useThemeMock.mockReturnValue({
+      ...CoreTheme.supersetTheme,
+      brandLogoUrl: '/superset/static/assets/images/custom-logo.png',
+      brandLogoHref: '/superset/welcome/',
+    });
+
+    render(<Menu {...mockedProps} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    const brandLink = await screen.findByRole('link', {
+      name: /apache superset/i,
+    });
+    // The internal brand logo link goes through StyledBrandLink -> GenericLink
+    // -> react-router <Link>. The production Router re-prepends the app root, so
+    // the value handed to GenericLink must already be stripped to avoid a
+    // doubled /superset/superset/... href. (Real-basename coverage of the
+    // resulting rendered href lives in Menu.subdirectory.test.tsx.)
+    expect(observedGenericLinkTo).toBe('/welcome/');
+    expect(brandLink).toHaveAttribute('href', '/welcome/');
+    const brandImg = brandLink.querySelector('img');
+    expect(brandImg).toHaveAttribute(
+      'src',
+      '/superset/static/assets/images/custom-logo.png',
+    );
+  });
+});
+
 // --- Active tab highlighting (regression tests for issue #36403) ---
 //
 // The active top-level tab is highlighted by matching the current route to a
diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx
index a7e8d2dcdf3..6819b05e0b0 100644
--- a/superset-frontend/src/features/home/Menu.tsx
+++ b/superset-frontend/src/features/home/Menu.tsx
@@ -20,7 +20,7 @@ import { useState, useEffect } from 'react';
 import { styled, css, useTheme } from '@apache-superset/core/theme';
 import { t } from '@apache-superset/core/translation';
 import { ensureStaticPrefix } from 'src/utils/assetUrl';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
 import { getUrlParam, isUrlExternal } from 'src/utils/urlUtils';
 import { MainNav, MenuItem } from '@superset-ui/core/components/Menu';
 import { Tooltip, Grid, Row, Col, Image } from '@superset-ui/core/components';
@@ -258,10 +258,18 @@ export function Menu({
     // of the localized label. Fall back to the label when no name is provided.
     const key = name ?? label;
     if (url && isFrontendRoute) {
+      // `<Router basename={applicationRoot()}>` re-prepends the app root to
+      // `to`, so handing it the already-rooted `url` from bootstrap_data
+      // would render a doubled `/superset/superset/...` anchor. Strip the
+      // root first; mirrors the brand-link treatment below.
       return {
         key,
         label: (
-          <NavLink role="button" to={url} activeClassName="is-active">
+          <NavLink
+            role="button"
+            to={stripAppRoot(url)}
+            activeClassName="is-active"
+          >
             {label}
           </NavLink>
         ),
@@ -288,7 +296,11 @@ export function Menu({
           // collide with that parent. Fall back to the label when no name.
           key: child.name ?? `${child.label}`,
           label: child.isFrontendRoute ? (
-            <NavLink to={child.url || ''} exact activeClassName="is-active">
+            <NavLink
+              to={stripAppRoot(child.url || '')}
+              exact
+              activeClassName="is-active"
+            >
               {child.label}
             </NavLink>
           ) : (
@@ -327,7 +339,18 @@ export function Menu({
               {brandImage}
             </Typography.Link>
           ) : (
-            <StyledBrandLink to={brandHref}>{brandImage}</StyledBrandLink>
+            // StyledBrandLink wraps GenericLink -> react-router <Link>, and
+            // `<Router basename={applicationRoot()}>` re-prepends the app root
+            // to `to`. Strip the root so the rendered anchor is single-prefixed
+            // rather than a doubled `/superset/superset/...`. Strip `brandHref`
+            // (the ensureAppRoot'd value) rather than the raw
+            // `theme.brandLogoHref` so an unset href (partial theme override)
+            // stays null-safe — `ensureAppRoot(undefined)` yields the app root,
+            // which `stripAppRoot` then reduces to `/`. Mirrors the brand.path
+            // branch's single-prefix treatment.
+            <StyledBrandLink to={stripAppRoot(brandHref)}>
+              {brandImage}
+            </StyledBrandLink>
           )}
         </StyledBrandWrapper>
       );
@@ -335,8 +358,12 @@ export function Menu({
       // ---------------------------------------------------------------------------------
       // TODO: deprecate this once Theme is fully rolled out
       // Kept as is for backwards compatibility with the old theme system / superset_config.py
+      //
+      // `<Router basename={applicationRoot()}>` re-prepends the app root to the
+      // `to` prop, so handing it an already-rooted `brand.path` would render a
+      // doubled `/superset/superset/...` href. Strip the root first.
       link = (
-        <GenericLink className="navbar-brand" to={brand.path}>
+        <GenericLink className="navbar-brand" to={stripAppRoot(brand.path)}>
           <StyledImage
             preview={false}
             src={ensureStaticPrefix(brand.icon)}
diff --git a/superset-frontend/src/features/home/RightMenu.test.tsx b/superset-frontend/src/features/home/RightMenu.test.tsx
index 83cdbdf6eb5..d57f676cd17 100644
--- a/superset-frontend/src/features/home/RightMenu.test.tsx
+++ b/superset-frontend/src/features/home/RightMenu.test.tsx
@@ -26,6 +26,7 @@ import {
 } from 'spec/helpers/testing-library';
 import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core';
 import { isEmbedded } from 'src/dashboard/util/isEmbedded';
+import * as getBootstrapData from 'src/utils/getBootstrapData';
 import RightMenu from './RightMenu';
 import { GlobalMenuDataOptions, RightMenuProps } from './types';
 
@@ -495,3 +496,66 @@ test('hides logout button when embedded and flag is enabled', async () => {
   userEvent.hover(await screen.findByText(/Settings/i));
   expect(screen.queryByText('Logout')).not.toBeInTheDocument();
 });
+
+test('Info link href is single-prefixed under subdirectory deployment', async () => {
+  // Backend emits a bare leading-slash path (`/user_info/` or `/users/userinfo/`).
+  // RightMenu wraps it with ensureAppRoot, which reads applicationRoot()
+  // dynamically. Under SUPERSET_APP_ROOT=/superset the rendered href must
+  // be exactly `/superset/users/userinfo/` — not `/users/userinfo/` (no
+  // prefix → 404) or `/superset/superset/users/userinfo/` (double prefix).
+  const applicationRootSpy = jest
+    .spyOn(getBootstrapData, 'applicationRoot')
+    .mockReturnValue('/superset');
+
+  try {
+    resetUseSelectorMock();
+    render(<RightMenu {...createProps()} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    userEvent.hover(await screen.findByText(/Settings/i));
+    const infoLink = await screen.findByText('Info');
+    expect(infoLink.closest('a')).toHaveAttribute(
+      'href',
+      '/superset/users/userinfo/',
+    );
+  } finally {
+    applicationRootSpy.mockRestore();
+  }
+});
+
+test('Logout link href is single-prefixed under subdirectory deployment', async () => {
+  // The logout URL is built by Flask-AppBuilder's get_url_for_logout, which
+  // is SCRIPT_NAME-aware and returns `/superset/logout/` under app_root.
+  // The frontend then routes it through ensureAppRoot, whose idempotence
+  // contract (see pathUtils.parity.test.ts) must prevent doubling.
+  const applicationRootSpy = jest
+    .spyOn(getBootstrapData, 'applicationRoot')
+    .mockReturnValue('/superset');
+
+  try {
+    const props = createProps();
+    // Mirror the SCRIPT_NAME-prefixed value the backend would emit under
+    // APPLICATION_ROOT=/superset.
+    props.navbarRight.user_logout_url = '/superset/logout/';
+    resetUseSelectorMock();
+    render(<RightMenu {...props} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+
+    userEvent.hover(await screen.findByText(/Settings/i));
+    const logoutLink = await screen.findByText('Logout');
+    expect(logoutLink.closest('a')).toHaveAttribute(
+      'href',
+      '/superset/logout/',
+    );
+  } finally {
+    applicationRootSpy.mockRestore();
+  }
+});
diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx
index d05abae30c9..a9296a3498d 100644
--- a/superset-frontend/src/features/home/RightMenu.tsx
+++ b/superset-frontend/src/features/home/RightMenu.tsx
@@ -53,7 +53,7 @@ import {
   TelemetryPixel,
 } from '@superset-ui/core/components';
 import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
 import { isEmbedded } from 'src/dashboard/util/isEmbedded';
 import { findPermission } from 'src/utils/findPermission';
 import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -427,7 +427,7 @@ const RightMenu = ({
               items.push({
                 key: menu.label,
                 label: isFrontendRoute(menu.url) ? (
-                  <Link to={menu.url || ''}>{menu.label}</Link>
+                  <Link to={stripAppRoot(menu.url || '')}>{menu.label}</Link>
                 ) : (
                   <Typography.Link href={ensureAppRoot(menu.url || '')}>
                     {menu.label}
@@ -443,7 +443,7 @@ const RightMenu = ({
           items.push({
             key: menu.label,
             label: isFrontendRoute(menu.url) ? (
-              <Link to={menu.url || ''}>{menu.label}</Link>
+              <Link to={stripAppRoot(menu.url || '')}>{menu.label}</Link>
             ) : (
               <Typography.Link href={ensureAppRoot(menu.url || '')}>
                 {menu.label}
@@ -478,7 +478,9 @@ const RightMenu = ({
             sectionItems.push({
               key: child.label,
               label: isFrontendRoute(child.url) ? (
-                <Link to={child.url || ''}>{menuItemDisplay}</Link>
+                <Link to={stripAppRoot(child.url || '')}>
+                  {menuItemDisplay}
+                </Link>
               ) : (
                 <Typography.Link
                   href={child.url || ''}
diff --git a/superset-frontend/src/features/home/SavedQueries.tsx b/superset-frontend/src/features/home/SavedQueries.tsx
index 5e977dd8c7c..01a8e1428df 100644
--- a/superset-frontend/src/features/home/SavedQueries.tsx
+++ b/superset-frontend/src/features/home/SavedQueries.tsx
@@ -45,7 +45,6 @@ import {
   shortenSQL,
 } from 'src/views/CRUD/utils';
 import { assetUrl } from 'src/utils/assetUrl';
-import { ensureAppRoot } from 'src/utils/pathUtils';
 import { navigateTo } from 'src/utils/navigationUtils';
 import SubMenu from './SubMenu';
 import EmptyState from './EmptyState';
@@ -306,7 +305,8 @@ export const SavedQueries = ({
             <CardStyles key={q.id}>
               <ListViewCard
                 imgURL=""
-                url={ensureAppRoot(`/sqllab?savedQueryId=${q.id}`)}
+                url={`/sqllab?savedQueryId=${q.id}`}
+                linkComponent={Link}
                 title={q.label}
                 imgFallbackURL={assetUrl(
                   '/static/assets/images/empty-query.svg',
diff --git a/superset-frontend/src/middleware/logger.test.ts b/superset-frontend/src/middleware/logger.test.ts
index f54c154d54d..5b52f3d682c 100644
--- a/superset-frontend/src/middleware/logger.test.ts
+++ b/superset-frontend/src/middleware/logger.test.ts
@@ -88,13 +88,13 @@ describe('logger middleware', () => {
     expect(next.mock.calls.length).toBe(1);
   });
 
-  test('should POST an event to /superset/log/ when called', () => {
+  test('should POST an event to /log/ when called', () => {
     (logger as Function)(mockStore)(next)(action);
     expect(next.mock.calls.length).toBe(0);
 
     jest.advanceTimersByTime(2000);
     expect(postStub.mock.calls.length).toBe(1);
-    expect(postStub.mock.calls[0][0].endpoint).toMatch('/superset/log/');
+    expect(postStub.mock.calls[0][0].endpoint).toMatch('/log/');
   });
 
   test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
@@ -160,7 +160,7 @@ describe('logger middleware', () => {
 
     expect(beaconMock.mock.calls.length).toBe(1);
     const endpoint = beaconMock.mock.calls[0][0];
-    expect(endpoint).toMatch('/superset/log/');
+    expect(endpoint).toMatch('/log/');
   });
 
   test('should pass a guest token to sendBeacon if present', () => {
diff --git a/superset-frontend/src/middleware/loggerMiddleware.ts b/superset-frontend/src/middleware/loggerMiddleware.ts
index a41d96a53e6..b16c75503df 100644
--- a/superset-frontend/src/middleware/loggerMiddleware.ts
+++ b/superset-frontend/src/middleware/loggerMiddleware.ts
@@ -29,11 +29,16 @@ import {
   LOG_ACTIONS_SPA_NAVIGATION,
 } from '../logger/LogUtils';
 import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
-import { ensureAppRoot } from '../utils/pathUtils';
+import { ensureAppRoot } from '../utils/navigationUtils';
 import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
 import type { QueryEditor } from '../SqlLab/types';
 
-type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
+type LogEventSource =
+  | 'dashboard'
+  | 'embedded_dashboard'
+  | 'explore'
+  | 'sqlLab'
+  | 'slice';
 
 interface LogEventData {
   source?: LogEventSource;
@@ -88,7 +93,7 @@ interface LoggerStore {
   dispatch: Dispatch;
 }
 
-const LOG_ENDPOINT = '/superset/log/?explode=events';
+const LOG_ENDPOINT = '/log/?explode=events';
 
 const sendBeacon = (events: LogEventData[]): void => {
   if (events.length <= 0) {
diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx
index 02d94cea471..9dca6fe73bf 100644
--- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx
+++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx
@@ -42,7 +42,7 @@ import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerM
 import { AnnotationLayerObject } from 'src/features/annotationLayers/types';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { navigateTo } from 'src/utils/navigationUtils';
+import { ensureAppRoot, navigateTo } from 'src/utils/navigationUtils';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
 
 const PAGE_SIZE = 25;
@@ -154,7 +154,9 @@ function AnnotationLayersList({
           }
 
           return (
-            <Typography.Link href={`/annotationlayer/${id}/annotation`}>
+            <Typography.Link
+              href={ensureAppRoot(`/annotationlayer/${id}/annotation`)}
+            >
               {name}
             </Typography.Link>
           );
diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx
index 68011f38772..948b1284a81 100644
--- a/superset-frontend/src/pages/AnnotationList/index.tsx
+++ b/superset-frontend/src/pages/AnnotationList/index.tsx
@@ -41,6 +41,7 @@ import { AnnotationObject } from 'src/features/annotations/types';
 import AnnotationModal from 'src/features/annotations/AnnotationModal';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { Typography } from '@superset-ui/core/components/Typography';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 
 const PAGE_SIZE = 25;
 
@@ -280,7 +281,7 @@ function AnnotationList({
               {hasHistory ? (
                 <Link to="/annotationlayer/list/">{t('Back to all')}</Link>
               ) : (
-                <Typography.Link href="/annotationlayer/list/">
+                <Typography.Link href={ensureAppRoot('/annotationlayer/list/')}>
                   {t('Back to all')}
                 </Typography.Link>
               )}
diff --git a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
index f177eaacf2c..7e95f888c23 100644
--- a/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
+++ b/superset-frontend/src/pages/ChartList/ChartList.listview.test.tsx
@@ -564,7 +564,7 @@ test('renders dashboard crosslinks as navigable links', async () => {
       within(crosslinks).getByRole('link', {
         name: new RegExp(dashboard.dashboard_title),
       }),
-    ).toHaveAttribute('href', `/superset/dashboard/${dashboard.id}`);
+    ).toHaveAttribute('href', `/dashboard/${dashboard.id}`);
   });
 });
 
@@ -604,7 +604,7 @@ test('shows tag column when TAGGING_SYSTEM is enabled', async () => {
 
   // Tag should be a link to all_entities page
   const tagLink = within(tag).getByRole('link');
-  expect(tagLink).toHaveAttribute('href', '/superset/all_entities/?id=1');
+  expect(tagLink).toHaveAttribute('href', '/all_entities/?id=1');
   expect(tagLink).toHaveAttribute('target', '_blank');
 });
 
diff --git a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx
index 1a27d5affeb..2ef4c28bf3d 100644
--- a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx
+++ b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx
@@ -33,7 +33,7 @@ export const mockHandleResourceExport =
 export const mockCharts = [
   {
     id: 0,
-    url: '/superset/slice/0/',
+    url: '/explore/?slice_id=0',
     viz_type: 'table',
     slice_name: 'Test Chart 0',
 
@@ -43,7 +43,7 @@ export const mockCharts = [
     tags: [{ name: 'basic', type: 1, id: 1 }],
 
     datasource_name_text: 'public.test_dataset',
-    datasource_url: '/superset/explore/table/1/',
+    datasource_url: '/explore/?datasource_type=table&datasource_id=1',
     datasource_id: 1,
 
     changed_by_name: 'user',
@@ -72,7 +72,7 @@ export const mockCharts = [
   },
   {
     id: 1,
-    url: '/superset/slice/1/',
+    url: '/explore/?slice_id=1',
     viz_type: 'bar',
     slice_name: 'Test Chart 1',
 
@@ -93,7 +93,7 @@ export const mockCharts = [
     ],
 
     datasource_name_text: 'sales_data',
-    datasource_url: '/superset/explore/table/2/',
+    datasource_url: '/explore/?datasource_type=table&datasource_id=2',
     datasource_id: 2,
 
     changed_by_name: 'admin',
@@ -119,7 +119,7 @@ export const mockCharts = [
   },
   {
     id: 2,
-    url: '/superset/slice/2/',
+    url: '/explore/?slice_id=2',
     viz_type: 'line',
     slice_name: 'Test Chart 2',
 
@@ -150,7 +150,7 @@ export const mockCharts = [
   },
   {
     id: 3,
-    url: '/superset/slice/3/',
+    url: '/explore/?slice_id=3',
     viz_type: 'area',
     slice_name: 'Test Chart 3',
 
@@ -168,7 +168,7 @@ export const mockCharts = [
     tags: [{ name: 'limit-test', type: 1, id: 10 }],
 
     datasource_name_text: 'public.limits_dataset',
-    datasource_url: '/superset/explore/table/4/',
+    datasource_url: '/explore/?datasource_type=table&datasource_id=4',
     datasource_id: 4,
 
     changed_by_name: 'limit_user',
@@ -189,7 +189,7 @@ export const mockCharts = [
   },
   {
     id: 4,
-    url: '/superset/slice/4/',
+    url: '/explore/?slice_id=4',
     viz_type: 'bubble',
     slice_name: 'Test Chart 4',
 
@@ -208,7 +208,7 @@ export const mockCharts = [
     tags: [{ name: 'overflow', type: 1, id: 11 }],
 
     datasource_name_text: 'public.overflow_dataset',
-    datasource_url: '/superset/explore/table/5/',
+    datasource_url: '/explore/?datasource_type=table&datasource_id=5',
     datasource_id: 5,
 
     changed_by_name: 'overflow_user',
diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx
index b5cc1e4ea20..876e634a1b5 100644
--- a/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx
+++ b/superset-frontend/src/pages/DashboardList/DashboardList.testHelpers.tsx
@@ -39,7 +39,7 @@ export const mockHandleResourceExport =
 export const mockDashboards = [
   {
     id: 1,
-    url: '/superset/dashboard/1/',
+    url: '/dashboard/1/',
     dashboard_title: 'Sales Dashboard',
     published: true,
     changed_by_name: 'admin',
@@ -61,7 +61,7 @@ export const mockDashboards = [
   },
   {
     id: 2,
-    url: '/superset/dashboard/2/',
+    url: '/dashboard/2/',
     dashboard_title: 'Analytics Dashboard',
     published: false,
     changed_by_name: 'analyst',
@@ -86,7 +86,7 @@ export const mockDashboards = [
   },
   {
     id: 3,
-    url: '/superset/dashboard/3/',
+    url: '/dashboard/3/',
     dashboard_title: 'Executive Overview',
     published: true,
     changed_by_name: 'admin',
@@ -111,7 +111,7 @@ export const mockDashboards = [
   },
   {
     id: 4,
-    url: '/superset/dashboard/4/',
+    url: '/dashboard/4/',
     dashboard_title: 'Marketing Metrics',
     published: false,
     changed_by_name: 'marketing',
@@ -133,7 +133,7 @@ export const mockDashboards = [
   },
   {
     id: 5,
-    url: '/superset/dashboard/5/',
+    url: '/dashboard/5/',
     dashboard_title: 'Ops Monitor',
     published: true,
     changed_by_name: 'ops',
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index b78eb0dd652..08efc789987 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -55,6 +55,7 @@ import {
 } from 'src/components';
 import { Typography } from '@superset-ui/core/components/Typography';
 import { getUrlParam } from 'src/utils/urlUtils';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import { URL_PARAMS } from 'src/constants';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -1032,7 +1033,7 @@ function DatabaseList({
                           avatar={<span>•</span>}
                           title={
                             <Typography.Link
-                              href={`/superset/dashboard/${result.id}`}
+                              href={ensureAppRoot(`/dashboard/${result.id}`)}
                               target="_atRiskItem"
                             >
                               {result.title}
@@ -1075,7 +1076,9 @@ function DatabaseList({
                           avatar={<span>•</span>}
                           title={
                             <Typography.Link
-                              href={`/explore/?slice_id=${result.id}`}
+                              href={ensureAppRoot(
+                                `/explore/?slice_id=${result.id}`,
+                              )}
                               target="_atRiskItem"
                             >
                               {result.slice_name}
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx
new file mode 100644
index 00000000000..2c8717f35d4
--- /dev/null
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.subdirectory.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { act, screen, waitFor } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import { Provider } from 'react-redux';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryParamProvider } from 'use-query-params';
+import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
+import { render } from 'spec/helpers/testing-library';
+
+// DatasetList statically imports applicationRoot(); intercept it so we can
+// simulate a subdirectory deployment. Mirrors the Tab.subdirectory pattern.
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    default: actual.default,
+    applicationRoot: () => mockApplicationRoot(),
+    staticAssetsPrefix: actual.staticAssetsPrefix,
+  };
+});
+
+// eslint-disable-next-line import/first
+import DatasetList from 'src/pages/DatasetList';
+// eslint-disable-next-line import/first
+import {
+  setupMocks,
+  mockDatasetListEndpoints,
+  mockAdminUser,
+  mockDatasets,
+  createMockStore,
+  createDefaultStoreState,
+} from './DatasetList.testHelpers';
+
+const APP_ROOT = '/superset';
+
+const renderUnderSubdirectory = () => {
+  const store = createMockStore({
+    ...createDefaultStoreState(mockAdminUser),
+    user: mockAdminUser,
+  });
+  return render(
+    <Provider store={store}>
+      {/* Mirror production's `<Router basename={applicationRoot()}>`
+          (views/App.tsx). BrowserRouter honours basename in createHref, so a
+          router-relative `to` is re-prefixed once — exactly the seam the strip
+          must feed correctly. */}
+      <BrowserRouter basename={APP_ROOT}>
+        <QueryParamProvider adapter={ReactRouter5Adapter}>
+          <DatasetList user={mockAdminUser} />
+        </QueryParamProvider>
+      </BrowserRouter>
+    </Provider>,
+  );
+};
+
+beforeEach(() => {
+  setupMocks();
+  mockApplicationRoot.mockReturnValue(APP_ROOT);
+  // Put the page URL under the basename so BrowserRouter is consistent.
+  window.history.replaceState({}, '', `${APP_ROOT}/tablemodelview/list/`);
+});
+
+afterEach(async () => {
+  jest.useRealTimers();
+  await act(async () => {
+    await new Promise(resolve => setTimeout(resolve, 0));
+  });
+  window.history.replaceState({}, '', '/');
+  fetchMock.clearHistory().removeRoutes();
+  jest.restoreAllMocks();
+});
+
+test('explore link is single-prefixed under a subdirectory deployment', async () => {
+  // The backend emits explore_url already carrying the application root. The
+  // Router basename re-prefixes the root, so the strip must run first to avoid
+  // a doubled /superset/superset/... href.
+  const dataset = {
+    ...mockDatasets[0],
+    explore_url: `${APP_ROOT}/explore/?datasource=1__table`,
+  };
+  mockDatasetListEndpoints({ result: [dataset], count: 1 });
+
+  renderUnderSubdirectory();
+
+  await waitFor(() => {
+    expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+  });
+
+  const exploreLink = screen.getByRole('link', { name: dataset.table_name });
+  expect(exploreLink).toHaveAttribute(
+    'href',
+    `${APP_ROOT}/explore/?datasource=1__table`,
+  );
+  expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
+});
+
+test('external default_endpoint passes through unprefixed', async () => {
+  const dataset = {
+    ...mockDatasets[0],
+    explore_url: 'https://external.example.com/custom-endpoint',
+  };
+  mockDatasetListEndpoints({ result: [dataset], count: 1 });
+
+  renderUnderSubdirectory();
+
+  await waitFor(() => {
+    expect(screen.getByText(dataset.table_name)).toBeInTheDocument();
+  });
+
+  const exploreLink = screen.getByRole('link', { name: dataset.table_name });
+  expect(exploreLink.getAttribute('href')).toMatch(
+    /^https:\/\/external\.example\.com\/custom-endpoint/,
+  );
+});
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 4c655548b23..e82c5228e16 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -71,6 +71,7 @@ import {
 import type { SelectOption } from 'src/components/ListView/types';
 import { Typography } from '@superset-ui/core/components/Typography';
 import handleResourceExport from 'src/utils/export';
+import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
 import SubMenu, { SubMenuProps, ButtonProps } from 'src/features/home/SubMenu';
 import Owner from 'src/types/Owner';
 import withToasts from 'src/components/MessageToasts/withToasts';
@@ -704,10 +705,17 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
             },
           },
         }: CellProps<Dataset>) => {
+          // `explore_url` arrives router-relative from the backend (already
+          // carrying the application root under a subdirectory deployment).
+          // react-router's <Link>/<GenericLink> resolve `to` against the
+          // Router basename, which re-prefixes the root — so strip it here to
+          // avoid a doubled `/superset/superset/...`. External
+          // `default_endpoint` URLs pass through unchanged.
+          const exploreTo = stripAppRoot(exploreURL);
           let titleLink: JSX.Element;
           if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
             titleLink = (
-              <Link data-test="internal-link" to={exploreURL}>
+              <Link data-test="internal-link" to={exploreTo}>
                 {datasetTitle}
               </Link>
             );
@@ -715,7 +723,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
             titleLink = (
               // exploreUrl can be a link to Explore or an external link
               // in the first case use SPA routing, else use HTML anchor
-              <GenericLink to={exploreURL}>{datasetTitle}</GenericLink>
+              <GenericLink to={exploreTo}>{datasetTitle}</GenericLink>
             );
           }
           try {
@@ -1396,7 +1404,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
                           avatar={<span>•</span>}
                           title={
                             <Typography.Link
-                              href={`/superset/dashboard/${result.id}`}
+                              href={ensureAppRoot(`/dashboard/${result.id}`)}
                               target="_atRiskItem"
                             >
                               {result.title}
@@ -1439,7 +1447,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
                           avatar={<span>•</span>}
                           title={
                             <Typography.Link
-                              href={`/explore/?slice_id=${result.id}`}
+                              href={ensureAppRoot(
+                                `/explore/?slice_id=${result.id}`,
+                              )}
                               target="_atRiskItem"
                             >
                               {result.slice_name}
diff --git a/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx b/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx
new file mode 100644
index 00000000000..f7d1942f850
--- /dev/null
+++ b/superset-frontend/src/pages/FileHandler/FileHandler.subdirectory.test.tsx
@@ -0,0 +1,267 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ComponentType } from 'react';
+import { Route, Router } from 'react-router-dom';
+import { createMemoryHistory, MemoryHistory } from 'history';
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import FileHandler from './index';
+
+// Subdirectory regression for the five `history.push('/welcome/')` emitters
+// in FileHandler. The sibling `index.test.tsx` mocks `useHistory` and so can
+// only assert the value the emitter passes to `push`; this module wires up
+// the real react-router pathway (real `<Router>` + a `createMemoryHistory`
+// the test owns) and asserts that the resulting `history.location.pathname`
+// resolves to `/welcome/` regardless of whether the user arrived under the
+// root deployment (`/file-handler`) or a `/superset` subdirectory deployment
+// (`/superset/file-handler`).
+//
+// Note on basename composition: Superset's dependency tree pairs
+// `react-router-dom@5.3.4` with `history@5.3.0`, but the `basename` prop on
+// `<BrowserRouter>` is silently dropped in that combination (history v5 has
+// no basename support; react-router-dom v5 was designed for history v4). The
+// production subdirectory deployment composes the prefix at the URL-emitting
+// layer via `applicationRoot()` in non-router callers (see
+// `SliceHeaderControls.subdirectory.test.tsx`), and the in-app router relies
+// on the unprefixed routes matching whatever path the user arrived at. As a
+// result, asserting `history.createHref(...)` here would not exercise any
+// real composition — so this module pins only what is meaningful in this
+// stack: that the emitter pushes the unprefixed route and the post-push
+// location reflects it.
+
+const mockAddDangerToast = jest.fn();
+const mockAddSuccessToast = jest.fn();
+
+jest.setTimeout(60000);
+
+type ToastInjectedProps = {
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+};
+
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+  __esModule: true,
+  default: (Component: ComponentType<ToastInjectedProps>) =>
+    function MockedWithToasts(props: Record<string, unknown>) {
+      return (
+        <Component
+          {...props}
+          addDangerToast={mockAddDangerToast}
+          addSuccessToast={mockAddSuccessToast}
+        />
+      );
+    },
+}));
+
+interface UploadDataModalProps {
+  show: boolean;
+  onHide: () => void;
+  type: string;
+  allowedExtensions: string[];
+  fileListOverride?: File[];
+}
+
+jest.mock('src/features/databases/UploadDataModel', () => ({
+  __esModule: true,
+  default: ({
+    show,
+    onHide,
+    type,
+    allowedExtensions,
+    fileListOverride,
+  }: UploadDataModalProps) => (
+    <div data-test="upload-modal">
+      <div data-test="modal-show">{show.toString()}</div>
+      <div data-test="modal-type">{type}</div>
+      <div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
+      <div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
+      <button onClick={onHide} type="button">
+        Close
+      </button>
+    </div>
+  ),
+}));
+
+// NOTE: deliberately NO jest.mock('react-router-dom', ...) here — this module
+// exists precisely to exercise the real useHistory() pathway, not a mock.
+
+type MockFileHandle = {
+  kind: 'file';
+  name: string;
+  getFile: () => Promise<File>;
+  isSameEntry: () => Promise<boolean>;
+  queryPermission: () => Promise<PermissionState>;
+  requestPermission: () => Promise<PermissionState>;
+};
+
+const createMockFileHandle = (
+  fileName: string,
+  opts: { throwOnGetFile?: boolean } = {},
+): MockFileHandle => ({
+  kind: 'file',
+  name: fileName,
+  getFile: opts.throwOnGetFile
+    ? async () => {
+        throw new Error('File access denied');
+      }
+    : async () => new File(['test'], fileName),
+  isSameEntry: async () => false,
+  queryPermission: async () => 'granted',
+  requestPermission: async () => 'granted',
+});
+
+type LaunchQueue = {
+  setConsumer: (
+    consumer: (params: { files?: MockFileHandle[] }) => void,
+  ) => void;
+};
+
+const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
+const MAX_CONSUMER_POLL_ATTEMPTS = 50;
+
+// Mirrors `setupLaunchQueue` in index.test.tsx: defer the consumer to a
+// macrotask so it doesn't fire synchronously inside the component's useEffect
+// (the MessageChannel mock in jsDomWithFetchAPI forces React to schedule via
+// setTimeout, and inline consumer calls deadlock Jest).
+const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
+  let savedConsumer:
+    | ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
+    | null = null;
+  (window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
+    setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
+      savedConsumer = consumer;
+      if (fileHandle) {
+        const id = setTimeout(() => {
+          pendingTimerIds.delete(id);
+          consumer({ files: [fileHandle] });
+        }, 0);
+        pendingTimerIds.add(id);
+      }
+    },
+  };
+  return {
+    triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
+      let attempts = 0;
+      while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
+        // eslint-disable-next-line no-await-in-loop
+        await new Promise(resolve => {
+          setTimeout(resolve, 0);
+        });
+        attempts += 1;
+      }
+      if (!savedConsumer) {
+        throw new Error(
+          `LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
+        );
+      }
+      await savedConsumer(params);
+    },
+  };
+};
+
+type DeploymentLabel = 'root' | 'subdir';
+
+const ENTRY_PATHS: Record<DeploymentLabel, string> = {
+  root: '/file-handler',
+  subdir: '/superset/file-handler',
+};
+
+const renderUnderEntry = (entryPath: string): MemoryHistory => {
+  const history = createMemoryHistory({ initialEntries: [entryPath] });
+  render(
+    <Router history={history}>
+      <Route path={entryPath}>
+        <FileHandler />
+      </Route>
+    </Router>,
+    { useRedux: true },
+  );
+  return history;
+};
+
+const expectNavigatedToWelcome = async (
+  history: MemoryHistory,
+): Promise<void> => {
+  await waitFor(() => {
+    expect(history.location.pathname).toBe('/welcome/');
+  });
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  delete (window as unknown as Window & { launchQueue?: LaunchQueue })
+    .launchQueue;
+});
+
+afterEach(() => {
+  pendingTimerIds.forEach(id => clearTimeout(id));
+  pendingTimerIds.clear();
+  delete (window as unknown as Window & { launchQueue?: LaunchQueue })
+    .launchQueue;
+});
+
+// Run each redirect scenario under both deployment shapes. Both must end at
+// `/welcome/`; the test fails if a future maintainer re-introduces the
+// `/superset/` prefix into the emitter at any of the five call sites.
+const DEPLOYMENTS: DeploymentLabel[] = ['root', 'subdir'];
+
+DEPLOYMENTS.forEach(label => {
+  const entryPath = ENTRY_PATHS[label];
+
+  test(`launchQueue unsupported → /welcome/ under ${label} (${entryPath})`, async () => {
+    const history = renderUnderEntry(entryPath);
+    await expectNavigatedToWelcome(history);
+  });
+
+  test(`no files provided → /welcome/ under ${label} (${entryPath})`, async () => {
+    const { triggerConsumer } = setupLaunchQueue();
+    const history = renderUnderEntry(entryPath);
+    await triggerConsumer({ files: [] });
+    await expectNavigatedToWelcome(history);
+  });
+
+  test(`unsupported file type → /welcome/ under ${label} (${entryPath})`, async () => {
+    const { triggerConsumer } = setupLaunchQueue();
+    const history = renderUnderEntry(entryPath);
+    await triggerConsumer({ files: [createMockFileHandle('test.pdf')] });
+    await expectNavigatedToWelcome(history);
+  });
+
+  test(`getFile() error → /welcome/ under ${label} (${entryPath})`, async () => {
+    const { triggerConsumer } = setupLaunchQueue();
+    const history = renderUnderEntry(entryPath);
+    await triggerConsumer({
+      files: [createMockFileHandle('test.csv', { throwOnGetFile: true })],
+    });
+    await expectNavigatedToWelcome(history);
+  });
+
+  test(`modal close → /welcome/ under ${label} (${entryPath})`, async () => {
+    setupLaunchQueue(createMockFileHandle('test.csv'));
+    const history = renderUnderEntry(entryPath);
+    const modal = await screen.findByTestId('upload-modal');
+    expect(modal).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+    await expectNavigatedToWelcome(history);
+  });
+});
diff --git a/superset-frontend/src/pages/FileHandler/index.test.tsx b/superset-frontend/src/pages/FileHandler/index.test.tsx
index a4496625fa7..b51149daaa0 100644
--- a/superset-frontend/src/pages/FileHandler/index.test.tsx
+++ b/superset-frontend/src/pages/FileHandler/index.test.tsx
@@ -201,7 +201,7 @@ test('shows error when launchQueue is not supported', async () => {
     expect(mockAddDangerToast).toHaveBeenCalledWith(
       'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
     );
-    expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
+    expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
   });
 });
 
@@ -221,7 +221,7 @@ test('redirects when no files are provided', async () => {
   await triggerConsumer({ files: [] });
 
   await waitFor(() => {
-    expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
+    expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
   });
 });
 
@@ -326,7 +326,7 @@ test('shows error for unsupported file type', async () => {
     expect(mockAddDangerToast).toHaveBeenCalledWith(
       'Unsupported file type. Please use CSV, Excel, or Columnar files.',
     );
-    expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
+    expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
   });
 });
 
@@ -378,7 +378,7 @@ test('handles errors during file processing', async () => {
     expect(mockAddDangerToast).toHaveBeenCalledWith(
       'Failed to open file. Please try again.',
     );
-    expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
+    expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
   });
 });
 
@@ -402,7 +402,7 @@ test('modal close redirects to welcome page', async () => {
   await userEvent.click(screen.getByRole('button', { name: 'Close' }));
 
   await waitFor(() => {
-    expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
+    expect(mockHistoryPush).toHaveBeenCalledWith('/welcome/');
   });
 });
 
diff --git a/superset-frontend/src/pages/FileHandler/index.tsx b/superset-frontend/src/pages/FileHandler/index.tsx
index 589db966f74..ea24ce9383d 100644
--- a/superset-frontend/src/pages/FileHandler/index.tsx
+++ b/superset-frontend/src/pages/FileHandler/index.tsx
@@ -59,13 +59,13 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
             'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
           ),
         );
-        history.push('/superset/welcome/');
+        history.push('/welcome/');
         return;
       }
 
       launchQueue.setConsumer(async (launchParams: FileLaunchParams) => {
         if (!launchParams.files || launchParams.files.length === 0) {
-          history.push('/superset/welcome/');
+          history.push('/welcome/');
           return;
         }
 
@@ -92,7 +92,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
                 'Unsupported file type. Please use CSV, Excel, or Columnar files.',
               ),
             );
-            history.push('/superset/welcome/');
+            history.push('/welcome/');
             return;
           }
 
@@ -103,7 +103,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
         } catch (error) {
           console.error('Error handling file launch:', error);
           addDangerToast(t('Failed to open file. Please try again.'));
-          history.push('/superset/welcome/');
+          history.push('/welcome/');
         }
       });
     };
@@ -115,7 +115,7 @@ const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
     setShowModal(false);
     setUploadFile(null);
     setUploadType(null);
-    history.push('/superset/welcome/');
+    history.push('/welcome/');
   };
 
   if (!uploadFile || !uploadType) {
diff --git a/superset-frontend/src/pages/Login/Login.test.tsx b/superset-frontend/src/pages/Login/Login.test.tsx
index 03e9ad72671..3a20d9c70b8 100644
--- a/superset-frontend/src/pages/Login/Login.test.tsx
+++ b/superset-frontend/src/pages/Login/Login.test.tsx
@@ -31,15 +31,25 @@ const defaultBootstrapData = {
   },
 };
 
-jest.mock('src/utils/getBootstrapData', () => ({
-  __esModule: true,
-  default: jest.fn(() => defaultBootstrapData),
-}));
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => {
+  const actual = jest.requireActual<
+    typeof import('src/utils/getBootstrapData')
+  >('src/utils/getBootstrapData');
+  return {
+    __esModule: true,
+    ...actual,
+    default: jest.fn(() => defaultBootstrapData),
+    applicationRoot: () => mockApplicationRoot(),
+  };
+});
 
 const mockGetBootstrapData = getBootstrapData as jest.Mock;
 
 beforeEach(() => {
   mockGetBootstrapData.mockReturnValue(defaultBootstrapData);
+  mockApplicationRoot.mockReturnValue('');
 });
 
 test('should render login form elements', () => {
@@ -81,3 +91,52 @@ test('should render SAML provider buttons', () => {
   expect(screen.getByText('Sign in with Okta')).toBeInTheDocument();
   expect(screen.getByText('Sign in with Onelogin')).toBeInTheDocument();
 });
+
+const samlBootstrapData = {
+  common: {
+    conf: {
+      AUTH_TYPE: 5,
+      AUTH_PROVIDERS: [{ name: 'okta', icon: 'okta' }],
+      AUTH_USER_REGISTRATION: false,
+    },
+  },
+};
+
+test('provider login links are root-relative on root deployments', () => {
+  mockGetBootstrapData.mockReturnValue(samlBootstrapData);
+  render(<Login />, { useRedux: true });
+  expect(
+    screen.getByRole('link', { name: /sign in with okta/i }),
+  ).toHaveAttribute('href', '/login/okta');
+});
+
+test('provider login links carry the application root on subdirectory deployments', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  mockGetBootstrapData.mockReturnValue(samlBootstrapData);
+  render(<Login />, { useRedux: true });
+  expect(
+    screen.getByRole('link', { name: /sign in with okta/i }),
+  ).toHaveAttribute('href', '/superset/login/okta');
+});
+
+test('provider login links preserve the next param under a subdirectory', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  mockGetBootstrapData.mockReturnValue(samlBootstrapData);
+  const next = '/superset/dashboard/1/';
+  window.history.replaceState(
+    {},
+    '',
+    `/superset/login/?next=${encodeURIComponent(next)}`,
+  );
+  try {
+    render(<Login />, { useRedux: true });
+    expect(
+      screen.getByRole('link', { name: /sign in with okta/i }),
+    ).toHaveAttribute(
+      'href',
+      `/superset/login/okta?next=${encodeURIComponent(next)}`,
+    );
+  } finally {
+    window.history.replaceState({}, '', '/');
+  }
+});
diff --git a/superset-frontend/src/pages/Login/index.tsx b/superset-frontend/src/pages/Login/index.tsx
index e585bf08f54..d704945d1fd 100644
--- a/superset-frontend/src/pages/Login/index.tsx
+++ b/superset-frontend/src/pages/Login/index.tsx
@@ -34,6 +34,7 @@ import { capitalize } from 'lodash/fp';
 import { addDangerToast } from 'src/components/MessageToasts/actions';
 import { useDispatch } from 'react-redux';
 import getBootstrapData from 'src/utils/getBootstrapData';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 
 type OAuthProvider = {
   name: string;
@@ -100,10 +101,10 @@ export default function Login() {
   );
 
   const buildProviderLoginUrl = (providerName: string) => {
-    const base = `/login/${providerName}`;
-    return nextUrl
-      ? `${base}${base.includes('?') ? '&' : '?'}next=${encodeURIComponent(nextUrl)}`
-      : base;
+    const base = `/login/${encodeURIComponent(providerName)}`;
+    return ensureAppRoot(
+      nextUrl ? `${base}?next=${encodeURIComponent(nextUrl)}` : base,
+    );
   };
 
   const authType: AuthType = bootstrapData.common.conf.AUTH_TYPE;
@@ -256,7 +257,7 @@ export default function Login() {
                     <Button
                       block
                       type="default"
-                      href="/register/"
+                      href={ensureAppRoot('/register/')}
                       data-test="register-button"
                     >
                       {t('Register')}
diff --git a/superset-frontend/src/pages/RedirectWarning/RedirectWarning.test.tsx b/superset-frontend/src/pages/RedirectWarning/RedirectWarning.test.tsx
new file mode 100644
index 00000000000..0507e0e6d5f
--- /dev/null
+++ b/superset-frontend/src/pages/RedirectWarning/RedirectWarning.test.tsx
@@ -0,0 +1,78 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import RedirectWarning from './index';
+
+// The interstitial page MUST visibly refuse a
+// backslash-laden URL — both the `isAllowedScheme` check *and* the rendered
+// shape have to communicate the block, so a user does not see a normal-
+// looking "External link warning" Card with a Continue button for what
+// browsers would normalise to `https://evil.com`.
+
+function setLocationSearch(search: string): void {
+  Object.defineProperty(window, 'location', {
+    value: {
+      search,
+      origin: 'https://example.com',
+      href: `https://example.com/redirect/${search}`,
+    },
+    writable: true,
+    configurable: true,
+  });
+}
+
+describe('RedirectWarning interstitial', () => {
+  test('does NOT offer a Continue button when ?url=%2F%5Cevil.com', () => {
+    setLocationSearch('?url=%2F%5Cevil.example.com');
+    render(<RedirectWarning />);
+    expect(
+      screen.queryByRole('button', { name: /continue/i }),
+    ).not.toBeInTheDocument();
+  });
+
+  test('shows a blocked-URL message for /\\evil.com', () => {
+    setLocationSearch('?url=%2F%5Cevil.example.com');
+    render(<RedirectWarning />);
+    // The page surfaces an unsafe / blocked state — assert the text shape
+    // without pinning the exact wording so copy changes do not break the
+    // security regression. The standard "External link warning" title must
+    // not appear for this input.
+    expect(
+      screen.queryByText(/external link warning/i),
+    ).not.toBeInTheDocument();
+  });
+
+  test('still renders the standard Continue flow for a legit https target', () => {
+    setLocationSearch('?url=https%3A%2F%2Fexample.com%2Fpage');
+    render(<RedirectWarning />);
+    expect(
+      screen.getByRole('button', { name: /continue/i }),
+    ).toBeInTheDocument();
+    expect(screen.getByText(/external link warning/i)).toBeInTheDocument();
+  });
+
+  test('shows the missing-URL state when no url query parameter is present', () => {
+    setLocationSearch('');
+    render(<RedirectWarning />);
+    expect(
+      screen.queryByRole('button', { name: /continue/i }),
+    ).not.toBeInTheDocument();
+    expect(screen.getByText(/missing url parameter/i)).toBeInTheDocument();
+  });
+});
diff --git a/superset-frontend/src/pages/RedirectWarning/index.tsx b/superset-frontend/src/pages/RedirectWarning/index.tsx
index 0f36da33ca8..246c7a288ee 100644
--- a/superset-frontend/src/pages/RedirectWarning/index.tsx
+++ b/superset-frontend/src/pages/RedirectWarning/index.tsx
@@ -28,6 +28,7 @@ import {
   Typography,
 } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
+import { redirect } from 'src/utils/navigationUtils';
 import { getTargetUrl, isUrlTrusted, trustUrl, isAllowedScheme } from './utils';
 
 const PageContainer = styled(Flex)`
@@ -99,7 +100,7 @@ export default function RedirectWarning() {
   // Redirect immediately if the URL is already trusted
   useEffect(() => {
     if (targetUrl && isAllowedScheme(targetUrl) && isUrlTrusted(targetUrl)) {
-      window.location.href = targetUrl;
+      redirect(targetUrl);
     }
   }, [targetUrl]);
 
@@ -108,11 +109,11 @@ export default function RedirectWarning() {
     if (trustChecked) {
       trustUrl(targetUrl);
     }
-    window.location.href = targetUrl;
+    redirect(targetUrl);
   }, [trustChecked, targetUrl]);
 
   const handleReturn = useCallback(() => {
-    window.location.href = '/';
+    redirect('/');
   }, []);
 
   if (!targetUrl) {
@@ -129,6 +130,37 @@ export default function RedirectWarning() {
     );
   }
 
+  // Defense-in-depth: when the URL's scheme is rejected we render a
+  // visible "blocked" state without a Continue button, instead of the
+  // standard "External link warning" Card. The handler also short-circuits
+  // on `isAllowedScheme(targetUrl)`, but rendering Continue for a URL we
+  // refuse to follow lets the page mislead the user into believing the
+  // target is safe to consent to.
+  if (!isAllowedScheme(targetUrl)) {
+    return (
+      <PageContainer justify="center" align="center">
+        <WarningCard>
+          <WarningHeader align="center" gap="middle">
+            <Icons.WarningOutlined iconColor={theme.colorError} iconSize="xl" />
+            <WarningTitle level={4}>{t('Unsafe link blocked')}</WarningTitle>
+          </WarningHeader>
+          <WarningBody>
+            <Typography.Paragraph type="secondary">
+              {t('This link cannot be followed because its address is unsafe.')}
+            </Typography.Paragraph>
+            <UrlDisplay align="center" gap="small">
+              <Icons.LinkOutlined iconColor={theme.colorTextTertiary} />
+              <UrlText>{targetUrl}</UrlText>
+            </UrlDisplay>
+          </WarningBody>
+          <WarningFooter justify="flex-end" gap="small">
+            <Button onClick={handleReturn}>{t('Return to Superset')}</Button>
+          </WarningFooter>
+        </WarningCard>
+      </PageContainer>
+    );
+  }
+
   return (
     <PageContainer justify="center" align="center">
       <WarningCard>
diff --git a/superset-frontend/src/pages/RedirectWarning/utils.test.ts b/superset-frontend/src/pages/RedirectWarning/utils.test.ts
index 0cb38a2d80f..7c9907cda10 100644
--- a/superset-frontend/src/pages/RedirectWarning/utils.test.ts
+++ b/superset-frontend/src/pages/RedirectWarning/utils.test.ts
@@ -56,6 +56,65 @@ test('isAllowedScheme allows relative URLs (unparseable as absolute)', () => {
   expect(isAllowedScheme('/dashboard/1')).toBe(true);
 });
 
+test('isAllowedScheme blocks protocol-relative URLs', () => {
+  // `new URL('//evil.example.com')` throws standalone, so without the
+  // explicit guard the catch branch would let cross-origin protocol-
+  // relative URLs through as "relative".
+  expect(isAllowedScheme('//evil.example.com')).toBe(false);
+  expect(isAllowedScheme('//evil.example.com/phish?token=abc')).toBe(false);
+});
+
+test('isAllowedScheme does not block single-leading-slash absolute paths', () => {
+  // Guard against an over-broad fix that strips both `//` and `/foo`.
+  expect(isAllowedScheme('/dashboard/list/')).toBe(true);
+});
+
+// The up-front
+// `startsWith('//')` check missed backslash variants. `new URL('/\\evil.com')`
+// throws → the catch returns `true` (allow) → the interstitial UI shows
+// `/\evil.com` inside an "External link warning" Card with a Continue
+// button. Browsers normalise `/\` → `//` in the special-scheme authority,
+// so the consented click became `https://evil.com`. The fix must reject
+// **before** the `new URL` attempt so the throw cannot route through the
+// allow branch.
+
+test('isAllowedScheme blocks /\\evil.com (backslash variant)', () => {
+  expect(isAllowedScheme('/\\evil.example.com')).toBe(false);
+});
+
+test('isAllowedScheme blocks \\/evil.com (backslash variant)', () => {
+  expect(isAllowedScheme('\\/evil.example.com')).toBe(false);
+});
+
+test('isAllowedScheme blocks \\\\evil.com (backslash variant)', () => {
+  expect(isAllowedScheme('\\\\evil.example.com')).toBe(false);
+});
+
+test('isAllowedScheme blocks /\\evil.com BEFORE the new URL attempt (not via the catch)', () => {
+  // Stub `URL` to throw — the result must still be `false`, proving the
+  // backslash rejection happens up-front and not via the catch's
+  // "relative URLs — allow" branch. (If the rejection were inside the
+  // try-block, a URL-constructor throw would route through `catch { return
+  // true }` and we'd see `true` here.)
+  const originalURL = global.URL;
+  global.URL = class {
+    constructor() {
+      throw new Error('stubbed URL constructor');
+    }
+  } as unknown as typeof URL;
+  try {
+    expect(isAllowedScheme('/\\evil.example.com')).toBe(false);
+  } finally {
+    global.URL = originalURL;
+  }
+});
+
+test('isAllowedScheme still blocks the catch-branch protocol-relative case after backslash rejection', () => {
+  // Regression: hardening must not accidentally drop the existing `//host`
+  // protection.
+  expect(isAllowedScheme('//evil.example.com')).toBe(false);
+});
+
 test('getTargetUrl reads the url query parameter', () => {
   const locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
     search: '?url=https%3A%2F%2Fexample.com%2Fpage',
diff --git a/superset-frontend/src/pages/RedirectWarning/utils.ts b/superset-frontend/src/pages/RedirectWarning/utils.ts
index 43aa834789a..e9cd66299c1 100644
--- a/superset-frontend/src/pages/RedirectWarning/utils.ts
+++ b/superset-frontend/src/pages/RedirectWarning/utils.ts
@@ -36,8 +36,23 @@ function normalizeUrl(url: string): string {
 /**
  * Return true if the URL scheme is safe for navigation.
  * Blocks javascript:, data:, vbscript:, file:, etc.
+ *
+ * Protocol-relative URLs (`//host/...`) are rejected because they perform a
+ * cross-origin navigation despite parsing as "relative" — the standalone
+ * `new URL(...)` call throws on them, so without an explicit guard the catch
+ * branch would let them through.
+ *
+ * Backslash variants (`/\host`, `\/host`, `\\host`, and any URL containing a
+ * backslash) are rejected up-front, BEFORE the `new URL` attempt. Browsers
+ * normalise `/\` → `//` in special-scheme authorities, so a backslash
+ * anywhere in the input lets an attacker craft a cross-origin target that
+ * presents as router-relative to the eye. Without the explicit rejection,
+ * `new URL('/\\evil.com')` would throw and the catch branch would return
+ * `true`, allowing the interstitial UI to display the URL as if it were a
+ * safe relative path.
  */
 export function isAllowedScheme(url: string): boolean {
+  if (/^[/\\][/\\]/.test(url) || url.includes('\\')) return false;
   try {
     const parsed = new URL(url);
     return ALLOWED_SCHEMES.includes(parsed.protocol);
diff --git a/superset-frontend/src/pages/Register/index.tsx b/superset-frontend/src/pages/Register/index.tsx
index cb962687de1..9c89ef41b6e 100644
--- a/superset-frontend/src/pages/Register/index.tsx
+++ b/superset-frontend/src/pages/Register/index.tsx
@@ -32,6 +32,7 @@ import { useState } from 'react';
 import getBootstrapData from 'src/utils/getBootstrapData';
 import ReactCAPTCHA from 'react-google-recaptcha';
 import { useParams } from 'react-router-dom';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 
 interface RegisterForm {
   username: string;
@@ -91,7 +92,11 @@ export default function Login() {
             'Your account is activated. You can log in with your credentials.',
           )}
           extra={[
-            <Button type="default" href="/login/" data-test="login-button">
+            <Button
+              type="default"
+              href={ensureAppRoot('/login/')}
+              data-test="login-button"
+            >
               {t('Login')}
             </Button>,
           ]}
diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx
index 92dde1d21bd..c390c5f3001 100644
--- a/superset-frontend/src/pages/SavedQueryList/index.tsx
+++ b/superset-frontend/src/pages/SavedQueryList/index.tsx
@@ -66,7 +66,7 @@ import type Owner from 'src/types/Owner';
 import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
 import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal';
 import { findPermission } from 'src/utils/findPermission';
-import { makeUrl } from 'src/utils/pathUtils';
+import { getShareableUrl, openInNewTab } from 'src/utils/navigationUtils';
 
 const PAGE_SIZE = 25;
 const PASSWORDS_NEEDED_MESSAGE = t(
@@ -244,11 +244,8 @@ function SavedQueryList({
 
   // Action methods
   const openInSqlLab = (id: number, openInNewWindow: boolean) => {
-    copyTextToClipboard(() =>
-      Promise.resolve(
-        `${window.location.origin}${makeUrl(`/sqllab?savedQueryId=${id}`)}`,
-      ),
-    )
+    const path = `/sqllab?savedQueryId=${id}`;
+    copyTextToClipboard(() => Promise.resolve(getShareableUrl(path)))
       .then(() => {
         addSuccessToast(t('Link Copied!'));
       })
@@ -256,11 +253,11 @@ function SavedQueryList({
         addDangerToast(t('Sorry, your browser does not support copying.'));
       });
     if (openInNewWindow) {
-      window.open(makeUrl(`/sqllab?savedQueryId=${id}`));
+      openInNewTab(path);
     } else {
       // React Router's basename already includes the application root; passing
       // a relative path ensures correct navigation under subdirectory deployments.
-      history.push(`/sqllab?savedQueryId=${id}`);
+      history.push(path);
     }
   };
 
diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx
index f18cf0d4f29..9527f4808ca 100644
--- a/superset-frontend/src/pages/Tags/index.tsx
+++ b/superset-frontend/src/pages/Tags/index.tsx
@@ -168,7 +168,7 @@ function TagList(props: TagListProps) {
           },
         }: any) => (
           <AntdTag>
-            <Link to={`/superset/all_entities/?id=${id}`}>{tagName}</Link>
+            <Link to={`/all_entities/?id=${id}`}>{tagName}</Link>
           </AntdTag>
         ),
         Header: t('Name'),
diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts
index cc304da84d3..c9f64c4160a 100644
--- a/superset-frontend/src/preamble.ts
+++ b/superset-frontend/src/preamble.ts
@@ -26,7 +26,7 @@ import setupFormatters from './setup/setupFormatters';
 import setupDashboardComponents from './setup/setupDashboardComponents';
 import { User } from './types/bootstrapTypes';
 import getBootstrapData, { applicationRoot } from './utils/getBootstrapData';
-import { makeUrl } from './utils/pathUtils';
+import { makeUrl } from './utils/navigationUtils';
 import './hooks/useLocale';
 
 // Import dayjs plugin types for global TypeScript support
@@ -73,7 +73,7 @@ export default function initPreamble(): Promise<void> {
       }, LANGUAGE_PACK_REQUEST_TIMEOUT_MS);
 
       try {
-        const languagePackUrl = makeUrl(`/superset/language_pack/${lang}/`);
+        const languagePackUrl = makeUrl(`/language_pack/${lang}/`);
         const resp = await fetch(languagePackUrl, {
           signal: abortController.signal,
         });
diff --git a/superset-frontend/src/pwa-manifest.json b/superset-frontend/src/pwa-manifest.json
deleted file mode 100644
index fcd8f2213eb..00000000000
--- a/superset-frontend/src/pwa-manifest.json
+++ /dev/null
@@ -1,65 +0,0 @@
-{
-  "name": "Apache Superset",
-  "short_name": "Superset",
-  "description": "Modern data exploration and visualization platform",
-  "start_url": "/superset/welcome/",
-  "scope": "/",
-  "display": "standalone",
-  "background_color": "#ffffff",
-  "theme_color": "#20a7c9",
-  "icons": [
-    {
-      "src": "/static/assets/images/pwa/icon-192.png",
-      "sizes": "192x192",
-      "type": "image/png",
-      "purpose": "any"
-    },
-    {
-      "src": "/static/assets/images/pwa/icon-512.png",
-      "sizes": "512x512",
-      "type": "image/png",
-      "purpose": "any"
-    },
-    {
-      "src": "/static/assets/images/pwa/icon-192.png",
-      "sizes": "192x192",
-      "type": "image/png",
-      "purpose": "maskable"
-    },
-    {
-      "src": "/static/assets/images/pwa/icon-512.png",
-      "sizes": "512x512",
-      "type": "image/png",
-      "purpose": "maskable"
-    }
-  ],
-  "screenshots": [
-    {
-      "src": "/static/assets/images/pwa/screenshot-wide.png",
-      "sizes": "1280x720",
-      "type": "image/png",
-      "form_factor": "wide",
-      "label": "Apache Superset Dashboard"
-    },
-    {
-      "src": "/static/assets/images/pwa/screenshot-narrow.png",
-      "sizes": "540x720",
-      "type": "image/png",
-      "form_factor": "narrow",
-      "label": "Apache Superset Mobile View"
-    }
-  ],
-  "file_handlers": [
-    {
-      "action": "/superset/file-handler",
-      "accept": {
-        "text/csv": [".csv"],
-        "application/vnd.ms-excel": [".xls"],
-        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
-          ".xlsx"
-        ],
-        "application/vnd.apache.parquet": [".parquet"]
-      }
-    }
-  ]
-}
diff --git a/superset-frontend/src/utils/assetUrl.test.ts b/superset-frontend/src/utils/assetUrl.test.ts
index f0de28b2c77..3d8307f6b3b 100644
--- a/superset-frontend/src/utils/assetUrl.test.ts
+++ b/superset-frontend/src/utils/assetUrl.test.ts
@@ -46,3 +46,42 @@ describe('assetUrl should ignore static asset prefix for absolute URLs', () => {
     expect(ensureStaticPrefix(absoluteResourcePath)).toBe(absoluteResourcePath);
   });
 });
+
+describe('ensureStaticPrefix should be idempotent', () => {
+  test('does not double-prefix a path that already starts with the static-assets prefix', () => {
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    const alreadyRooted =
+      '/superset/static/assets/images/superset-logo-horiz.png';
+    expect(ensureStaticPrefix(alreadyRooted)).toBe(alreadyRooted);
+  });
+
+  test('still prefixes a relative path that does not yet carry the prefix', () => {
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    expect(ensureStaticPrefix('/static/assets/x.png')).toBe(
+      '/superset/static/assets/x.png',
+    );
+  });
+
+  test('treats segment boundaries — `/supersetfoo` is not the same as `/superset`', () => {
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    expect(ensureStaticPrefix('/supersetfoo/img.png')).toBe(
+      '/superset/supersetfoo/img.png',
+    );
+  });
+
+  test('handles nested application roots', () => {
+    staticAssetsPrefixMock.mockReturnValue('/preset/superset');
+    const alreadyRooted = '/preset/superset/static/x.png';
+    expect(ensureStaticPrefix(alreadyRooted)).toBe(alreadyRooted);
+  });
+
+  test('returns the prefix itself unchanged when passed in bare', () => {
+    staticAssetsPrefixMock.mockReturnValue('/superset');
+    expect(ensureStaticPrefix('/superset')).toBe('/superset');
+  });
+
+  test('is a no-op when the static-assets prefix is empty (root deployment)', () => {
+    staticAssetsPrefixMock.mockReturnValue('');
+    expect(ensureStaticPrefix('/static/x.png')).toBe('/static/x.png');
+  });
+});
diff --git a/superset-frontend/src/utils/assetUrl.ts b/superset-frontend/src/utils/assetUrl.ts
index a7fc2553b50..72a07eff5bd 100644
--- a/superset-frontend/src/utils/assetUrl.ts
+++ b/superset-frontend/src/utils/assetUrl.ts
@@ -30,10 +30,21 @@ export function assetUrl(path: string): string {
 /**
  * Returns the path prepended with the staticAssetsPrefix if the string is a relative path else it returns
  * the string as is.
+ *
+ * Idempotent — a path that already begins with the static-assets prefix at a
+ * segment boundary is returned unchanged, mirroring the dedupe pattern used by
+ * `ensureAppRoot` in pathUtils.ts and `SupersetClient.getUrl`.
+ *
  * @param url_or_path A url or relative path to a resource
  */
 export function ensureStaticPrefix(url_or_path: string): string {
-  if (url_or_path.startsWith('/')) return assetUrl(url_or_path);
-
-  return url_or_path;
+  if (!url_or_path.startsWith('/')) return url_or_path;
+  const prefix = staticAssetsPrefix();
+  if (
+    prefix &&
+    (url_or_path === prefix || url_or_path.startsWith(`${prefix}/`))
+  ) {
+    return url_or_path;
+  }
+  return assetUrl(url_or_path);
 }
diff --git a/superset-frontend/src/utils/getBootstrapData.test.ts b/superset-frontend/src/utils/getBootstrapData.test.ts
index a8076bd2f4e..db2e30d2766 100644
--- a/superset-frontend/src/utils/getBootstrapData.test.ts
+++ b/superset-frontend/src/utils/getBootstrapData.test.ts
@@ -111,33 +111,84 @@ describe('getBootstrapData and helpers', () => {
     });
 
     test.each([
-      ['//evil.example.com', 'protocol-relative URL'],
+      ['markup payload', '"><script>x</script>/'],
+      ['protocol-relative URL', '//evil.example.com/app/'],
+      ['absolute URL', 'https://evil.example.com/app/'],
       // eslint-disable-next-line no-script-url -- intentional unsafe value under test
-      ['javascript:alert(1)', 'javascript scheme'],
-      ['https://evil.example.com', 'absolute URL'],
-      ['/foo"><img src=x>', 'path with HTML meta-characters'],
+      ['javascript scheme', 'javascript:alert(1)'],
+      ['embedded quote', "/my'app/"],
+      ['path traversal', '/app/..'],
+      ['double-dot segment', '/legit/../etc'],
+      ['backslash separator', '/app\\admin'],
+      ['leading double slash', '//app'],
+      ['double trailing slash', '/app//'],
+      ['unicode segment', '/café'],
+      ['tab control character', '/app\tadmin'],
+      ['newline control character', '/app\nadmin'],
+      ['null byte', '/app\x00admin'],
     ])(
-      'should fall back to the default root when application_root is %s (%s)',
-      async unsafeRoot => {
+      'should degrade a non-path application_root to root deployment (%s)',
+      async (_label, applicationRootValue) => {
         const customData = {
           common: {
-            application_root: unsafeRoot,
+            application_root: applicationRootValue,
             static_assets_prefix: '/custom-static/',
           },
         };
-        document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
+        document.body.innerHTML = `<div id="app"></div>`;
+        document
+          .getElementById('app')
+          ?.setAttribute('data-bootstrap', JSON.stringify(customData));
 
         jest.resetModules();
         const { default: getBootstrapData, applicationRoot } =
           await import('./getBootstrapData');
         getBootstrapData();
 
-        const expectedAppRoot =
-          DEFAULT_BOOTSTRAP_DATA.common.application_root.replace(/\/$/, '');
-        expect(applicationRoot()).toEqual(expectedAppRoot);
+        expect(applicationRoot()).toEqual('');
       },
     );
 
+    // Percent-encoded traversal sequences are NOT decoded or rejected here.
+    // application_root is operator-controlled server-rendered configuration
+    // (see SECURITY.md trust-boundary 2); the sanitizer only enforces the
+    // documented path shape on the literal value. Encoded forms like "%2e%2e"
+    // are valid path-segment characters and pass through unchanged — this is
+    // intentional and out of scope, documented here so the behavior is pinned.
+    test('should preserve a percent-encoded application_root (operator-controlled, out of scope)', async () => {
+      const customData = {
+        common: {
+          application_root: '/app/%2e%2e',
+          static_assets_prefix: '/custom-static/',
+        },
+      };
+      document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
+
+      jest.resetModules();
+      const { default: getBootstrapData, applicationRoot } =
+        await import('./getBootstrapData');
+      getBootstrapData();
+
+      expect(applicationRoot()).toEqual('/app/%2e%2e');
+    });
+
+    test('should preserve a multi-segment application_root', async () => {
+      const customData = {
+        common: {
+          application_root: '/team-a/superset/',
+          static_assets_prefix: '/custom-static/',
+        },
+      };
+      document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
+
+      jest.resetModules();
+      const { default: getBootstrapData, applicationRoot } =
+        await import('./getBootstrapData');
+      getBootstrapData();
+
+      expect(applicationRoot()).toEqual('/team-a/superset');
+    });
+
     test('should defaults without trailing slashes when #app element does not include application_root or static_assets_prefix', async () => {
       // Set up the fake #app element
       const customData = {
diff --git a/superset-frontend/src/utils/getBootstrapData.ts b/superset-frontend/src/utils/getBootstrapData.ts
index 8413bf860d2..5cf66a944f1 100644
--- a/superset-frontend/src/utils/getBootstrapData.ts
+++ b/superset-frontend/src/utils/getBootstrapData.ts
@@ -38,36 +38,36 @@ const normalizePathWithFallback = (
   fallback: string,
 ): string => (path ?? fallback).replace(/\/$/, '');
 
-/**
- * Matches a plain absolute path prefix (e.g. "" for root deployments or
- * "/analytics" for a subdirectory). The character after the leading slash must
- * not be another slash, so protocol-relative URLs ("//host") and scheme-bearing
- * values ("javascript:...") do not qualify.
- */
-const SAFE_APPLICATION_ROOT_RE = /^(\/[\w\-.][\w\-./]*)?$/;
+// The application root is server-rendered operator configuration, but it
+// flows into URL sinks across the app (script.src in ExtensionsLoader,
+// history.pushState in navigationUtils), so enforce the documented
+// "/segment(/segment)*" shape at the source: a value that doesn't conform
+// degrades to a root deployment rather than reaching those sinks.
+// Each segment must begin with a non-dot character so a literal "." or ".."
+// segment (e.g. "/app/..") is rejected — otherwise browser path normalization
+// would collapse `ensureAppRoot('/foo')` → `/app/../foo` → `/foo`, silently
+// defeating subdirectory containment. Dots are still allowed inside a
+// segment (e.g. "/foo.bar").
+//
+// This guards the *literal* value only. Percent-encoded dot segments
+// ("/app/%2e%2e") pass through unchanged: `%` is a permitted path-segment
+// character and the regex does not decode. A browser would later normalize
+// "/app/%2e%2e/foo" → "/foo", so such a value silently defeats subdirectory
+// prefixing — but application_root is operator-controlled, server-rendered
+// configuration (SECURITY.md trust-boundary 2), so an encoded-traversal value
+// is an unsupported misconfiguration, not an attacker-reachable input. It is
+// intentionally neither decoded nor rejected here; the behavior is pinned by a
+// test in getBootstrapData.test.ts.
+const APP_ROOT_PATH_RE = /^(?:\/[\w~%-]+(?:\.[\w~%-]+)*)*$/;
 
-/**
- * The application root (SUPERSET_APP_ROOT) is reflected into links and
- * navigation, so constrain it to a plain absolute path before use. Anything
- * that isn't a simple "/path" prefix falls back to the default root so a
- * malformed value can't be reinterpreted as HTML or redirect off-origin. This
- * also keeps the bootstrap-derived value from being treated as a tainted href
- * source by static analysis.
- */
-const sanitizeApplicationRoot = (
-  path: string | undefined,
-  fallback: string,
-): string => {
-  const normalizedFallback = normalizePathWithFallback(fallback, fallback);
-  const normalized = normalizePathWithFallback(path, fallback);
-  return SAFE_APPLICATION_ROOT_RE.test(normalized)
-    ? normalized
-    : normalizedFallback;
-};
+const sanitizeAppRoot = (root: string): string =>
+  APP_ROOT_PATH_RE.test(root) ? root : '';
 
-const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeApplicationRoot(
-  getBootstrapData().common.application_root,
-  DEFAULT_BOOTSTRAP_DATA.common.application_root,
+const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeAppRoot(
+  normalizePathWithFallback(
+    getBootstrapData().common.application_root,
+    DEFAULT_BOOTSTRAP_DATA.common.application_root,
+  ),
 );
 
 const STATIC_ASSETS_PREFIX_NO_TRAILING_SLASH = normalizePathWithFallback(
diff --git a/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx b/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx
new file mode 100644
index 00000000000..78709b75185
--- /dev/null
+++ b/superset-frontend/src/utils/navigationUtils.AppLink.test.tsx
@@ -0,0 +1,95 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render } from 'spec/helpers/testing-library';
+import { AppLink } from 'src/utils/navigationUtils';
+
+// AppLink renders a real React element via React Testing Library, which is
+// incompatible with the withApplicationRoot fixture's `jest.resetModules()`
+// (it corrupts the testing-library module graph). Mock applicationRoot at
+// file scope and vary per test instead. Variable must start with `mock` to
+// satisfy Jest's hoisted-factory out-of-scope check.
+const mockApplicationRoot = jest.fn<string, []>(() => '');
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: () => ({
+    common: { application_root: '', static_assets_prefix: '' },
+  }),
+  applicationRoot: () => mockApplicationRoot(),
+  staticAssetsPrefix: () => '',
+}));
+
+beforeEach(() => {
+  mockApplicationRoot.mockReturnValue('');
+});
+
+test('renders an anchor with prefixed href under subdirectory deployment', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const { container } = render(<AppLink href="/foo">go</AppLink>);
+  const anchor = container.querySelector('a');
+  expect(anchor).not.toBeNull();
+  expect(anchor?.getAttribute('href')).toBe('/superset/foo');
+});
+
+test('passes through other anchor props', () => {
+  const { container } = render(
+    <AppLink href="/foo" target="_blank" rel="noreferrer">
+      go
+    </AppLink>,
+  );
+  const anchor = container.querySelector('a');
+  expect(anchor?.getAttribute('target')).toBe('_blank');
+  expect(anchor?.getAttribute('rel')).toBe('noreferrer');
+});
+
+test('passes absolute URLs through without prefixing', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const { container } = render(
+    <AppLink href="https://external.example.com">x</AppLink>,
+  );
+  expect(container.querySelector('a')?.getAttribute('href')).toBe(
+    'https://external.example.com',
+  );
+});
+
+// AppLink runs `assertSafeNavigationUrl(ensureAppRoot(href))` during render, so
+// an unsafe href throws rather than emitting an anchor that points at the
+// attacker-controlled target. Mirror the openInNewTab / getShareableUrl pins.
+test('throws on a backslash-laden authority-spoof href', () => {
+  mockApplicationRoot.mockReturnValue('/superset');
+  const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+  try {
+    expect(() => render(<AppLink href="/\evil.com">x</AppLink>)).toThrow(
+      /refused unsafe URL/,
+    );
+  } finally {
+    errorSpy.mockRestore();
+  }
+});
+
+test('throws on a protocol-relative href', () => {
+  const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+  try {
+    expect(() =>
+      render(<AppLink href="//evil.example.com">x</AppLink>),
+    ).toThrow(/refused unsafe URL/);
+  } finally {
+    errorSpy.mockRestore();
+  }
+});
diff --git a/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx b/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx
new file mode 100644
index 00000000000..e623f00b1d3
--- /dev/null
+++ b/superset-frontend/src/utils/navigationUtils.appRoot.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { applicationRootScenarios } from 'spec/helpers/withApplicationRoot';
+
+// Subdirectory regression for the nine `ensureAppRoot(...)` value-wrap
+// callsites. Each row pins one callsite's input value
+// (what the migrated component now passes to `ensureAppRoot`) and asserts the
+// resulting URL under both the root deployment (`''`) and the `/superset`
+// subdirectory deployment.
+//
+// Why this shape rather than per-callsite rendered-component tests: every
+// migrated callsite is a value-only wrap (plain `<a href={ensureAppRoot(...)}>`,
+// antd `<Button href={ensureAppRoot(...)}>`, or
+// `<Typography.Link href={ensureAppRoot(...)}>`); the migration's correctness
+// is entirely determined by `ensureAppRoot`'s output. Rendering Login,
+// Register, DatabaseList, and DatasetList in full just to read back their
+// rendered `href` attributes would pull in dozens of unrelated providers and
+// fixtures with no extra signal. Pinning input → expected output per callsite
+// gives the same regression coverage with a fraction of the surface area.
+//
+// The line citations in `name` are pinned at the green commit; if a follow-up
+// edits move them, update the citations rather than the assertions.
+
+interface Callsite {
+  /** Human-friendly identifier (`file:line — element`). */
+  name: string;
+  /** The literal value the migrated component passes to `ensureAppRoot`. */
+  input: string;
+}
+
+const CALLSITES: Callsite[] = [
+  {
+    name: 'src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx:149 — <a target="_blank">',
+    input: '/annotationlayer/list',
+  },
+  {
+    name: 'src/pages/Login/index.tsx:260 — antd <Button href>',
+    input: '/register/',
+  },
+  {
+    name: 'src/pages/Register/index.tsx:95 — antd <Button href>',
+    input: '/login/',
+  },
+  {
+    name: 'src/pages/AnnotationLayerList/index.tsx:158 — <Typography.Link href>',
+    // Sample id (`42`) keeps the assertion concrete; the runtime caller
+    // interpolates `id` from the row data.
+    input: '/annotationlayer/42/annotation',
+  },
+  {
+    name: 'src/pages/AnnotationList/index.tsx:284 — <Typography.Link href>',
+    input: '/annotationlayer/list/',
+  },
+  {
+    name: 'src/pages/DatabaseList/index.tsx:1037 — <Typography.Link href> (legacy /superset/dashboard/ literal dropped)',
+    input: '/dashboard/42',
+  },
+  {
+    name: 'src/pages/DatabaseList/index.tsx:1082 — <Typography.Link href>',
+    input: '/explore/?slice_id=42',
+  },
+  {
+    name: 'src/pages/DatasetList/index.tsx:1363 — <Typography.Link href> (legacy /superset/dashboard/ literal dropped)',
+    input: '/dashboard/42',
+  },
+  {
+    name: 'src/pages/DatasetList/index.tsx:1408 — <Typography.Link href>',
+    input: '/explore/?slice_id=42',
+  },
+];
+
+const DEPLOYMENTS: { label: string; root: string }[] = [
+  { label: 'root deployment', root: '' },
+  { label: '/superset subdirectory deployment', root: '/superset' },
+];
+
+test('ensureAppRoot composes correctly under both deployments', async () => {
+  await applicationRootScenarios(DEPLOYMENTS, async ({ root }) => {
+    const { ensureAppRoot } = await import('src/utils/navigationUtils');
+    for (const callsite of CALLSITES) {
+      const expected =
+        root === '' ? callsite.input : `${root}${callsite.input}`;
+      expect({
+        deployment: root || '(root)',
+        callsite: callsite.name,
+        actual: ensureAppRoot(callsite.input),
+      }).toEqual({
+        deployment: root || '(root)',
+        callsite: callsite.name,
+        actual: expected,
+      });
+    }
+  });
+});
+
+// `ensureAppRoot` is idempotent on already-prefixed paths under the same
+// deployment root: the runtime dedupe net inside `pathUtils.ensureAppRoot`
+// (documented in the `project_supersetclient_approot_dedupe.md` memory)
+// recognises a leading `appRoot` segment and does not re-prefix. That is what
+// keeps the migration safe to roll out incrementally — if any third party,
+// SSR backend response, or pre-migration callsite still emits the legacy
+// `/superset/...` literal, the helper short-circuits to a single-prefix URL
+// instead of doubling it. Pin the behaviour here so a future "simplify"
+// refactor cannot accidentally strip the safety net.
+test('ensureAppRoot is idempotent on already-prefixed paths under /superset', async () => {
+  await applicationRootScenarios(
+    [{ label: '/superset subdirectory deployment', root: '/superset' }],
+    async () => {
+      const { ensureAppRoot } = await import('src/utils/navigationUtils');
+      // Legacy emitter shape: `/superset/dashboard/42` already carries the
+      // prefix. Re-wrapping must produce single-prefix, not doubled.
+      expect(ensureAppRoot('/superset/dashboard/42')).toBe(
+        '/superset/dashboard/42',
+      );
+      // New emitter shape: `/dashboard/42` is unprefixed and
+      // gets the deployment root composed in.
+      expect(ensureAppRoot('/dashboard/42')).toBe('/superset/dashboard/42');
+    },
+  );
+});
diff --git a/superset-frontend/src/utils/navigationUtils.directDom.test.tsx b/superset-frontend/src/utils/navigationUtils.directDom.test.tsx
new file mode 100644
index 00000000000..28a5b662340
--- /dev/null
+++ b/superset-frontend/src/utils/navigationUtils.directDom.test.tsx
@@ -0,0 +1,244 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { applicationRootScenarios } from 'spec/helpers/withApplicationRoot';
+
+// Subdirectory regression for the five direct-DOM-navigation callsites
+// migrated:
+//
+//   src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx:1163
+//     → openInNewTab(this.getSQLLabUrl())
+//   src/SqlLab/components/ResultSet/index.tsx:315
+//     → openInNewTab(url)            // url = mountExploreUrl(..., true)
+//   src/SqlLab/components/ResultSet/index.tsx:384
+//     → redirect(getExportCsvUrl(query.id))
+//   src/SqlLab/components/SaveDatasetModal/index.tsx:252
+//     → openInNewTab(url)            // url = mountExploreUrl(..., true)
+//   src/SqlLab/components/SaveDatasetModal/index.tsx:254
+//     → redirect(url)
+//
+// Each migrated callsite hands an already-app-rooted URL to the helper
+// (because the upstream URL builder — `makeUrl`, `mountExploreUrl(...,
+// includeAppRoot=true)` — has already applied the root). The helper must
+// produce a single-prefix URL under both deployments via the `ensureAppRoot`
+// idempotence safety net. The previous direct `window.open` / `window.location`
+// calls bypassed both `ensureAppRoot` (would double-prefix if the upstream
+// URL changed) and `assertSafeNavigationUrl` (open-redirect surface) — this
+// suite locks in that the helper now owns both responsibilities.
+//
+// We assert on the URL handed to the underlying DOM API rather than rendering
+// any of the three components (DatasourceEditor, ResultSet, SaveDatasetModal),
+// because the migration is purely a value-wrap on `window.open` /
+// `window.location.href` — there is no rendered surface that changes shape.
+
+interface Callsite {
+  /** Human-friendly identifier (`file:line — helper(arg-shape)`). */
+  name: string;
+  /** Helper invoked at the migrated callsite. */
+  helper: 'openInNewTab' | 'redirect';
+  /**
+   * The literal value the upstream URL builder hands to the helper at
+   * runtime, parameterised by the deployment root. For the migrated
+   * callsites this is always an already-app-rooted URL (idempotent re-
+   * application is the contract).
+   */
+  input: (root: string) => string;
+  /** The single-prefix URL the helper must hand to the underlying DOM API. */
+  expected: (root: string) => string;
+}
+
+const CALLSITES: Callsite[] = [
+  {
+    name: 'DatasourceEditor:1163 — openInNewTab(makeUrl(/sqllab/?...))',
+    helper: 'openInNewTab',
+    input: root => `${root}/sqllab/?datasourceKey=42`,
+    expected: root => `${root}/sqllab/?datasourceKey=42`,
+  },
+  {
+    name: 'ResultSet:315 — openInNewTab(mountExploreUrl(..., true))',
+    helper: 'openInNewTab',
+    input: root => `${root}/explore/?form_data_key=abc123`,
+    expected: root => `${root}/explore/?form_data_key=abc123`,
+  },
+  {
+    name: 'ResultSet:384 — redirect(getExportCsvUrl(id))',
+    helper: 'redirect',
+    input: root => `${root}/api/v1/sqllab/export/42/`,
+    expected: root => `${root}/api/v1/sqllab/export/42/`,
+  },
+  {
+    name: 'SaveDatasetModal:252 — openInNewTab(mountExploreUrl(..., true))',
+    helper: 'openInNewTab',
+    input: root => `${root}/explore/?form_data_key=def456`,
+    expected: root => `${root}/explore/?form_data_key=def456`,
+  },
+  {
+    name: 'SaveDatasetModal:254 — redirect(mountExploreUrl(..., true))',
+    helper: 'redirect',
+    input: root => `${root}/explore/?form_data_key=ghi789`,
+    expected: root => `${root}/explore/?form_data_key=ghi789`,
+  },
+];
+
+const DEPLOYMENTS: { label: string; root: string }[] = [
+  { label: 'root deployment', root: '' },
+  { label: '/superset subdirectory deployment', root: '/superset' },
+];
+
+test('helpers hand the correct single-prefix URL to the DOM under both deployments', async () => {
+  // Capture the original window.location.href descriptor so we can restore
+  // it after each scenario; jsdom defines it as a getter/setter on the
+  // Location prototype and we need a plain assignment to spy on.
+  const originalLocation = window.location;
+  const originalOpen = window.open;
+
+  await applicationRootScenarios(DEPLOYMENTS, async ({ root }) => {
+    const { openInNewTab, redirect } =
+      await import('src/utils/navigationUtils');
+
+    for (const callsite of CALLSITES) {
+      // Re-stub per callsite so cross-row contamination cannot mask a bug.
+      const openSpy = jest.fn();
+      window.open = openSpy as unknown as typeof window.open;
+
+      // `window.location.href = ...` is a setter on the Location object; we
+      // replace `window.location` entirely with a writable href stub. jsdom
+      // permits this when configurable=true (it is by default in jest-jsdom).
+      const hrefStub: { value: string } = { value: '' };
+      Object.defineProperty(window, 'location', {
+        configurable: true,
+        writable: true,
+        value: {
+          get href() {
+            return hrefStub.value;
+          },
+          set href(next: string) {
+            hrefStub.value = next;
+          },
+          assign(next: string) {
+            hrefStub.value = next;
+          },
+        },
+      });
+
+      const input = callsite.input(root);
+      const expected = callsite.expected(root);
+
+      if (callsite.helper === 'openInNewTab') {
+        openInNewTab(input);
+        expect({
+          deployment: root || '(root)',
+          callsite: callsite.name,
+          actual: openSpy.mock.calls[0]?.[0],
+        }).toEqual({
+          deployment: root || '(root)',
+          callsite: callsite.name,
+          actual: expected,
+        });
+      } else {
+        redirect(input);
+        expect({
+          deployment: root || '(root)',
+          callsite: callsite.name,
+          actual: hrefStub.value,
+        }).toEqual({
+          deployment: root || '(root)',
+          callsite: callsite.name,
+          actual: expected,
+        });
+      }
+    }
+  });
+
+  Object.defineProperty(window, 'location', {
+    configurable: true,
+    writable: true,
+    value: originalLocation,
+  });
+  window.open = originalOpen;
+});
+
+// Open-redirect guard: the helpers route every URL through
+// `assertSafeNavigationUrl`, which rejects backslash-laden paths, protocol-
+// relative authorities, and `javascript:` / `data:` schemes. The pre-migration
+// raw `window.open(url, ...)` / `window.location.href = url` callsites had
+// no such guard; pin the post-migration safety so a future "simplify"
+// refactor cannot accidentally re-introduce the open-redirect surface.
+test('openInNewTab / redirect reject open-redirect URL shapes', async () => {
+  await applicationRootScenarios(
+    [{ label: '/superset subdirectory deployment', root: '/superset' }],
+    async () => {
+      const { openInNewTab, redirect } =
+        await import('src/utils/navigationUtils');
+      // `redirect` swallows the throw inside `navigateTo` (logs + falls
+      // back to ensureAppRoot('/'))). `openInNewTab` calls
+      // `assertSafeNavigationUrl` directly and propagates the throw.
+      const consoleErrSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        // Protocol-relative authority — would navigate cross-origin.
+        expect(() => openInNewTab('//evil.example.com/x')).toThrow(
+          /unsafe URL/,
+        );
+        // Backslash-laden authority — browsers normalise `/\evil` → `//evil`.
+        expect(() => openInNewTab('/legit\\evil.example.com')).toThrow(
+          /unsafe URL/,
+        );
+        // `javascript:` is neutralised by `ensureAppRoot` (prefixed into a
+        // path segment, e.g. `/superset/javascript:alert(1)`), so the
+        // helper does NOT throw — the contract is "the URL handed to
+        // window.open is a relative path, never a script-scheme". Verify
+        // the prefix path by stubbing window.open and reading the arg.
+        const sideEffectSpy = jest.fn();
+        window.open = sideEffectSpy as unknown as typeof window.open;
+        // Construct the literal piecewise so oxlint's `no-script-url` rule
+        // does not flag the test for the exact shape it is asserting the
+        // navigation guard neutralises.
+        const scriptScheme = `${'java'}${'script:'}alert(1)`;
+        openInNewTab(scriptScheme);
+        expect(sideEffectSpy).toHaveBeenCalledWith(
+          `/superset/${scriptScheme}`,
+          '_blank',
+          'noopener noreferrer',
+        );
+        // `redirect` falls back to the home page rather than throwing.
+        const hrefStub: { value: string } = { value: '' };
+        Object.defineProperty(window, 'location', {
+          configurable: true,
+          writable: true,
+          value: {
+            get href() {
+              return hrefStub.value;
+            },
+            set href(next: string) {
+              hrefStub.value = next;
+            },
+            assign(next: string) {
+              hrefStub.value = next;
+            },
+          },
+        });
+        redirect('//evil.example.com/x');
+        expect(hrefStub.value).toBe('/superset/');
+      } finally {
+        consoleErrSpy.mockRestore();
+      }
+    },
+  );
+});
diff --git a/superset-frontend/src/utils/navigationUtils.invariants.test.ts b/superset-frontend/src/utils/navigationUtils.invariants.test.ts
new file mode 100644
index 00000000000..2d2b8d9c409
--- /dev/null
+++ b/superset-frontend/src/utils/navigationUtils.invariants.test.ts
@@ -0,0 +1,375 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  expectNoHits,
+  scanSource,
+  type ScanHit,
+} from 'spec/helpers/sourceTreeScanner';
+
+// Hits whose source line is a comment are not real violations. The scanner is
+// line-based and cannot tell apart `const u = '/superset/x'` from `// example
+// '/superset/x'` — explanatory JSDoc and block comments that reference the
+// doubled-prefix bug intentionally contain the literal. Strip comments from
+// each line and re-test the original pattern against what's left.
+//
+// The previous line-based predicate dropped any
+// line beginning with `/*`, which silently swallowed shapes like
+// `/* ignore */ window.open('/x')` — executable code following an inline
+// block comment. The structural strip below removes block-comment and
+// line-comment segments inline and re-runs the caller's pattern; only lines
+// whose substantive content still matches survive.
+function dropCommentLines(hits: ScanHit[], pattern: RegExp): ScanHit[] {
+  // Use a fresh, non-global RegExp instance for `.test()` so any `g` flag on
+  // the caller's pattern does not skew lastIndex across invocations.
+  const testRegex = pattern.flags.includes('g')
+    ? new RegExp(pattern.source, pattern.flags.replace('g', ''))
+    : pattern;
+  return hits.filter(({ text }) => {
+    let stripped = text.replace(/\/\*[\s\S]*?\*\//g, '');
+    stripped = stripped.replace(/\/\/.*$/, '');
+    const collapsed = stripped.trim();
+    if (collapsed === '') return false;
+    // JSDoc continuation in a multi-line block comment (no closer on this
+    // line): a leading `*`, the standalone closer `*/`, or an unclosed
+    // `/*` / `/**` opener.
+    if (
+      collapsed === '*' ||
+      collapsed === '*/' ||
+      collapsed.startsWith('* ') ||
+      (collapsed.startsWith('/*') && !collapsed.includes('*/'))
+    ) {
+      return false;
+    }
+    return testRegex.test(collapsed);
+  });
+}
+
+// Synthetic regression — pins the structural strip behaviour above.
+// Mutate `dropCommentLines` back to the line-based form and this fails on
+// the `/* ignore */ window.open(...)` row, the exact evasion shape the
+// rewrite closes.
+test('dropCommentLines: strips comments structurally, not by leading marker', () => {
+  const synthetic: ScanHit[] = [
+    {
+      file: 'src/synthetic.ts',
+      line: 1,
+      text: "/* ignore */ window.open('/x')",
+      match: 'window.open(',
+    },
+    {
+      file: 'src/synthetic.ts',
+      line: 2,
+      text: '/* pure inline block comment */',
+      match: '',
+    },
+    {
+      file: 'src/synthetic.ts',
+      line: 3,
+      text: "* `window.open('/x')` inside JSDoc continuation",
+      match: 'window.open(',
+    },
+    {
+      file: 'src/synthetic.ts',
+      line: 4,
+      text: "callSite(); // window.open('/x') trailing-comment example",
+      match: 'window.open(',
+    },
+    {
+      file: 'src/synthetic.ts',
+      line: 5,
+      text: "window.open('/x') // real call with trailing note",
+      match: 'window.open(',
+    },
+  ];
+  const surviving = dropCommentLines(synthetic, /\bwindow\.open\(/);
+  // Lines 1 and 5 have executable `window.open(` after stripping; the
+  // others are comment-only and must be dropped.
+  expect(surviving.map(h => h.line)).toEqual([1, 5]);
+});
+
+test('no file outside navigationUtils.ts imports from pathUtils', () => {
+  // pathUtils.ts is the implementation module; navigationUtils.ts re-exports
+  // its helpers as the single sanctioned entry point for the rest of the
+  // codebase. Callers should `import { ensureAppRoot } from
+  // 'src/utils/navigationUtils'` (or use the focused helpers there).
+  const hits = scanSource({
+    pattern: /from\s+['"](?:src\/utils\/pathUtils|\.\.?\/[\w./]*pathUtils)['"]/,
+    allowlist: ['src/utils/navigationUtils.ts'],
+  });
+
+  expectNoHits(
+    hits,
+    'Found direct imports from src/utils/pathUtils. Import from ' +
+      'src/utils/navigationUtils instead — it re-exports ensureAppRoot ' +
+      'and makeUrl, and exposes focused helpers (openInNewTab, redirect, ' +
+      'getShareableUrl, AppLink) for most call sites.',
+  );
+});
+
+// Raw absolute-from-root anchor hrefs (`href="/foo"`, `href={`/foo`}`, etc.)
+// bypass React Router's basename when rendered with `target="_blank"` and
+// produce 404s under subdirectory deployment. Migrate to either
+// `<AppLink href={...}>` or wrap with `ensureAppRoot()` so the application
+// root is applied exactly once.
+//
+// The negative lookbehind `(?<!\.)` excludes `obj.href = '/'` — that case
+// belongs to the navigateTo / window.location migration. Negative lookahead
+// `(?!\/)` excludes protocol-relative URLs (`href="//cdn..."`).
+const RAW_HREF_ABSOLUTE_PATH_PATTERN =
+  /(?<!\.)\bhref\s*=\s*(?:["']\/(?!\/)|\{\s*["']\/(?!\/)|\{\s*`\/(?!\/))/;
+
+// 7 entries removed; all callsites
+// migrated to `<AppLink>` (plain `<a>`) or `ensureAppRoot(...)` value-wrap
+// (antd `<Button href>`, `<Typography.Link href>`). The two scan + stale-
+// allowlist tests below now enforce a genuine zero on the RAW_HREF surface.
+const RAW_HREF_ABSOLUTE_PATH_ALLOWLIST: string[] = [];
+
+test('no raw absolute-path href in JSX outside the migration allow-list', () => {
+  const hits = scanSource({
+    pattern: RAW_HREF_ABSOLUTE_PATH_PATTERN,
+    allowlist: RAW_HREF_ABSOLUTE_PATH_ALLOWLIST,
+  });
+
+  expectNoHits(
+    hits,
+    'Found raw absolute-path href anchors. With target="_blank" these ' +
+      "bypass React Router's basename and 404 under /superset deployment. " +
+      'Migrate to <AppLink href={...}> from src/utils/navigationUtils, or ' +
+      'wrap the value with ensureAppRoot().',
+  );
+});
+
+test('RAW_HREF_ABSOLUTE_PATH_ALLOWLIST has no stale entries', () => {
+  // Every allow-listed file must still contain at least one raw-href
+  // violation. When a migration commit removes the last violation from a
+  // file, that file must be dropped from the allow-list in the same commit
+  // — otherwise the scan silently stops protecting the rest of the tree
+  // from regressions in that file.
+  const hitFiles = new Set(
+    scanSource({ pattern: RAW_HREF_ABSOLUTE_PATH_PATTERN }).map(
+      hit => hit.file,
+    ),
+  );
+
+  const stale = RAW_HREF_ABSOLUTE_PATH_ALLOWLIST.filter(
+    file => !hitFiles.has(file),
+  );
+
+  expect(stale).toEqual([]);
+});
+
+// Direct `applicationRoot()` calls are the escape hatch for reading
+// the deployment root. The sanctioned shape is to route everything through
+// `navigationUtils` helpers (which delegate to pathUtils.ensureAppRoot /
+// makeUrl). Only a small set of modules legitimately needs the raw root: the
+// definition itself, the bootstrap entry points that hand `appRoot` to
+// `setupClient`, the router basename wiring, and the pathUtils
+// implementation.
+const APPLICATION_ROOT_CALL_PATTERN = /\bapplicationRoot\s*\(/;
+
+const APPLICATION_ROOT_CALL_ALLOWLIST: string[] = [
+  // Sanctioned: definition + the 5 modules described in the project memory.
+  'src/utils/getBootstrapData.ts',
+  'src/utils/pathUtils.ts',
+  'src/preamble.ts',
+  'src/views/App.tsx',
+  'src/embedded/index.tsx',
+  // Migration targets: still call applicationRoot() directly to build paths
+  // outside the helper. Replace with ensureAppRoot / stripAppRoot /
+  // navigationUtils before dropping from this list.
+  'src/dashboard/components/nativeFilters/FilterBar/index.tsx',
+  'src/components/StreamingExportModal/useStreamingExport.ts',
+];
+
+test('applicationRoot() is called only from sanctioned modules', () => {
+  const hits = dropCommentLines(
+    scanSource({
+      pattern: APPLICATION_ROOT_CALL_PATTERN,
+      allowlist: APPLICATION_ROOT_CALL_ALLOWLIST,
+    }),
+    APPLICATION_ROOT_CALL_PATTERN,
+  );
+
+  expectNoHits(
+    hits,
+    'Found direct applicationRoot() calls outside the sanctioned set. ' +
+      'Most call sites should route through navigationUtils helpers ' +
+      '(ensureAppRoot / makeUrl / openInNewTab / AppLink) so the app root ' +
+      'is applied exactly once. If a new caller genuinely needs the raw ' +
+      'root, justify it and add the file to APPLICATION_ROOT_CALL_ALLOWLIST.',
+  );
+});
+
+test('APPLICATION_ROOT_CALL_ALLOWLIST has no stale entries', () => {
+  const hitFiles = new Set(
+    dropCommentLines(
+      scanSource({ pattern: APPLICATION_ROOT_CALL_PATTERN }),
+      APPLICATION_ROOT_CALL_PATTERN,
+    ).map(hit => hit.file),
+  );
+
+  const stale = APPLICATION_ROOT_CALL_ALLOWLIST.filter(
+    file => !hitFiles.has(file),
+  );
+
+  expect(stale).toEqual([]);
+});
+
+// Direct DOM navigation (`window.open(...)`, `window.location.href = ...`,
+// `window.location.assign(...)`, `window.location.replace(...)`) bypasses
+// `navigationUtils.openInNewTab` / `redirect` and therefore skips both
+// `ensureAppRoot` and the `assertSafeNavigationUrl` scheme guard. Under
+// subdirectory deployment they emit unprefixed paths that 404; in the open-
+// redirect threat model they accept `javascript:` / protocol-relative URLs
+// that the helpers refuse. Migration target: replace with the navigationUtils
+// helpers, or — for mailto:/external schemes — keep the raw call and add a
+// localized justification + this allow-list entry.
+const DIRECT_DOM_NAV_PATTERN =
+  /\bwindow\.(?:open\(|location\.(?:href\s*=|assign\(|replace\()|history\.(?:pushState\(|replaceState\())/;
+
+// SANCTIONED: files that legitimately call window.open / window.location /
+// window.history directly because the helpers themselves live here, or the
+// call shape is intentionally outside the navigationUtils contract (mailto:
+// links, OAuth popup-handle capture, external-doc opener, full-page login
+// redirect inside the HTTP client). The set is permanent; adding to it
+// requires a documented justification adjacent to the entry.
+const DIRECT_DOM_NAV_SANCTIONED: string[] = [
+  // Owns the helpers: openInNewTab / redirect / navigateTo / navigateWithState
+  // are the only callers that should reach for window.* in src/.
+  'src/utils/navigationUtils.ts',
+  // Dashboard reload-to-self after a layout change — `window.location.assign`
+  // is intentionally a full-page nav and the URL is already app-rooted.
+  'src/dashboard/components/Header/index.tsx',
+  // `mailto:` schemes are not router-relative; helpers reject them via the
+  // assertSafeNavigationUrl guard.
+  'src/dashboard/components/menu/ShareMenuItems/index.tsx',
+  'src/explore/components/useExploreAdditionalActionsMenu/index.tsx',
+  // SupersetClient login redirect: full-page reload to a server-built
+  // `${appRoot}/login?next=...` URL inside the connection layer, before
+  // navigationUtils is available.
+  'packages/superset-ui-core/src/connection/SupersetClientClass.ts',
+  // External documentation links opened via a user-supplied URL — the
+  // navigationUtils guard would reject any non-http(s) value here.
+  'packages/superset-ui-core/src/components/Form/LabeledErrorBoundInput.tsx',
+];
+
+// MIGRATION TARGETS: files that still call window.* directly and should be
+// drained to zero. The last three were drained (DatasourceEditor,
+// ResultSet, SaveDatasetModal); future direct-DOM navigations must either
+// migrate immediately or earn a justified SANCTIONED entry above.
+const DIRECT_DOM_NAV_ALLOWLIST: string[] = [];
+
+test('no direct window.open/window.location navigation outside navigationUtils', () => {
+  const hits = dropCommentLines(
+    scanSource({
+      pattern: DIRECT_DOM_NAV_PATTERN,
+      allowlist: [...DIRECT_DOM_NAV_SANCTIONED, ...DIRECT_DOM_NAV_ALLOWLIST],
+    }),
+    DIRECT_DOM_NAV_PATTERN,
+  );
+
+  expectNoHits(
+    hits,
+    'Found direct window.open / window.location / window.history navigation. ' +
+      'These bypass ensureAppRoot (broken under subdirectory deployment) ' +
+      'and the assertSafeNavigationUrl scheme guard (open-redirect surface). ' +
+      'Replace with openInNewTab / redirect / navigateWithState from ' +
+      'src/utils/navigationUtils.',
+  );
+});
+
+test('DIRECT_DOM_NAV_SANCTIONED has no stale entries', () => {
+  const hitFiles = new Set(
+    dropCommentLines(
+      scanSource({ pattern: DIRECT_DOM_NAV_PATTERN }),
+      DIRECT_DOM_NAV_PATTERN,
+    ).map(hit => hit.file),
+  );
+
+  const stale = DIRECT_DOM_NAV_SANCTIONED.filter(file => !hitFiles.has(file));
+
+  expect(stale).toEqual([]);
+});
+
+test('DIRECT_DOM_NAV_ALLOWLIST has no stale entries', () => {
+  const hitFiles = new Set(
+    dropCommentLines(
+      scanSource({ pattern: DIRECT_DOM_NAV_PATTERN }),
+      DIRECT_DOM_NAV_PATTERN,
+    ).map(hit => hit.file),
+  );
+
+  const stale = DIRECT_DOM_NAV_ALLOWLIST.filter(file => !hitFiles.has(file));
+
+  expect(stale).toEqual([]);
+});
+
+// Hard-coded `/superset/...` path literals in source bake the legacy
+// `route_base` into the frontend and double-prefix under subdirectory
+// deployment. The backend now serves these endpoints under their own
+// blueprint routes (e.g. `/dashboard/<id>/`, `/explore/...`, `/sqllab/`),
+// and the helper-aware shape is `ensureAppRoot('/dashboard/<id>/')`.
+//
+// Match a quote/backtick followed by `/superset` followed by `/` or a
+// closing quote. The lookahead also allows `?` and `#` so query/hash
+// shapes don't slip through.
+const HARDCODED_SUPERSET_LITERAL_PATTERN = /['"`]\/superset(?:[/?#]|['"`])/;
+
+// Empty: the last entries (`ChartList`/`DashboardList` testHelpers) were
+// dropped once `Dashboard.url` / `Slice.url` / `SqlaTable.explore_url` stopped
+// returning `/superset/...` and the fixtures were updated to the live shapes
+// (`/dashboard/<id>/`, `/explore/?slice_id=<id>`,
+// `/explore/?datasource_type=table&datasource_id=<id>`). Kept as an empty
+// constant — paired with the stale-entry guard below — so a future migration
+// can re-introduce a temporary entry without re-deriving the mechanism.
+const HARDCODED_SUPERSET_LITERAL_ALLOWLIST: string[] = [];
+
+test('no hard-coded /superset/ path literals outside the migration allow-list', () => {
+  const hits = dropCommentLines(
+    scanSource({
+      pattern: HARDCODED_SUPERSET_LITERAL_PATTERN,
+      allowlist: HARDCODED_SUPERSET_LITERAL_ALLOWLIST,
+    }),
+    HARDCODED_SUPERSET_LITERAL_PATTERN,
+  );
+
+  expectNoHits(
+    hits,
+    'Found hard-coded /superset/ path literal in source. Under subdirectory ' +
+      'deployment these become /<approot>/superset/... (doubled prefix) ' +
+      'and after the `Superset.route_base = ""` cleanup most are 404s. ' +
+      'Replace with the post-route_base route (e.g. /dashboard/<id>/, ' +
+      '/explore/...) and let ensureAppRoot apply the deployment root.',
+  );
+});
+
+test('HARDCODED_SUPERSET_LITERAL_ALLOWLIST has no stale entries', () => {
+  const hitFiles = new Set(
+    dropCommentLines(
+      scanSource({ pattern: HARDCODED_SUPERSET_LITERAL_PATTERN }),
+      HARDCODED_SUPERSET_LITERAL_PATTERN,
+    ).map(hit => hit.file),
+  );
+
+  const stale = HARDCODED_SUPERSET_LITERAL_ALLOWLIST.filter(
+    file => !hitFiles.has(file),
+  );
+
+  expect(stale).toEqual([]);
+});
diff --git a/superset-frontend/src/utils/navigationUtils.schemes.test.ts b/superset-frontend/src/utils/navigationUtils.schemes.test.ts
new file mode 100644
index 00000000000..2b1fa0439f6
--- /dev/null
+++ b/superset-frontend/src/utils/navigationUtils.schemes.test.ts
@@ -0,0 +1,207 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The existing navigationUtils tests pin `javascript:`-scheme neutralisation
+// (`navigationUtils.directDom.test.tsx:203-219`), but the broader
+// script/data/binary-content scheme family — `data:`, `vbscript:`, `file:`,
+// `blob:`, `chrome:` — was never explicitly pinned. The contract is the
+// same for every dangerous scheme: `ensureAppRoot` prefixes the URL, the
+// prefixed result starts with `/<appRoot>/` so the SAFE_NAVIGATION_URL_RE
+// leading-slash branch matches, and the actual browser navigation lands on
+// `/<appRoot>/<scheme:body>` — a 404'd path segment, NEVER an executable
+// scheme. The test pins each scheme so a future allow-list refactor can't
+// silently re-enable any of them.
+//
+// Backend cover for the same threat sits in
+// `superset/views/redirect.py` (DANGEROUS_SCHEMES frozenset) — verified by
+// `tests/unit_tests/views/test_redirect_view_subdirectory.py::
+// test_dangerous_schemes_blocked_under_subdir`. The two layers together
+// give defence-in-depth for the alert/report email-click path and the
+// in-app navigation path.
+
+import { withApplicationRoot } from 'spec/helpers/withApplicationRoot';
+
+jest.setTimeout(20000);
+
+// Each scheme is constructed piecewise to avoid oxlint's `no-script-url`
+// flagging the literal — same pattern the directDom test uses. Each entry:
+//   - label: human-readable scheme name
+//   - build(): returns the raw URL string; piecewise to dodge linters
+const DANGEROUS_SCHEMES: ReadonlyArray<{ label: string; build: () => string }> =
+  [
+    { label: 'data:', build: () => `${'data:'}text/html,<script>1</script>` },
+    { label: 'vbscript:', build: () => `${'vbscript:'}msgbox(1)` },
+    { label: 'file:', build: () => `${'file:'}///etc/passwd` },
+    { label: 'blob:', build: () => `${'blob:'}https://evil.example.com/abc` },
+    { label: 'chrome:', build: () => `${'chrome:'}//settings` },
+    { label: 'about:', build: () => `${'about:'}blank` },
+    // Uppercase scheme — case-insensitivity is a known bypass class for
+    // origin/scheme allow-lists. Pin that the prefix path is taken
+    // regardless of case (the SAFE_NAVIGATION_URL_RE is case-insensitive
+    // for the http/ftp branches; data: / vbscript: must fall through to
+    // the leading-slash branch via ensureAppRoot).
+    { label: 'DATA:', build: () => `${'DATA:'}text/html,<script>1</script>` },
+    { label: 'VBScript:', build: () => `${'VBScript:'}msgbox(1)` },
+  ];
+
+describe('openInNewTab neutralises dangerous schemes by appRoot prefix', () => {
+  let openSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    openSpy.mockRestore();
+  });
+
+  test.each(DANGEROUS_SCHEMES)(
+    'subdir deploy: $label is neutralised into a prefixed path segment',
+    async ({ build }: { label: string; build: () => string }) => {
+      const scheme = build();
+      await withApplicationRoot('/superset/', async () => {
+        const { openInNewTab } = await import('src/utils/navigationUtils');
+        openInNewTab(scheme);
+        expect(openSpy).toHaveBeenCalledTimes(1);
+        const [calledUrl] = openSpy.mock.calls[0];
+        // Contract: window.open receives `<appRoot>/<scheme:body>` — a 404'd
+        // path segment, never the original scheme.
+        expect(calledUrl).toBe(`/superset/${scheme}`);
+        // Defence-in-depth: the call MUST NOT start with any executable
+        // scheme. A future refactor that swaps `ensureAppRoot` for a
+        // scheme-aware passthrough would surface here.
+        expect(calledUrl).not.toMatch(
+          /^(?:data|vbscript|file|blob|chrome|about|javascript):/i,
+        );
+      });
+    },
+  );
+
+  test.each(DANGEROUS_SCHEMES)(
+    'root deploy: $label is neutralised into a prefixed path segment',
+    async ({ build }: { label: string; build: () => string }) => {
+      const scheme = build();
+      await withApplicationRoot('', async () => {
+        const { openInNewTab } = await import('src/utils/navigationUtils');
+        openInNewTab(scheme);
+        expect(openSpy).toHaveBeenCalledTimes(1);
+        const [calledUrl] = openSpy.mock.calls[0];
+        // With empty appRoot, ensureAppRoot is NOT a no-op for a dangerous
+        // scheme: a `data:...` string is not safe-absolute, so it gets a
+        // leading slash and becomes `/data:...`. SAFE_NAVIGATION_URL_RE then
+        // accepts it via the leading-slash branch, so `window.open` IS
+        // invoked — but with an inert same-origin path (`/data:...`, a 404),
+        // never an executable scheme. The neutralisation is the path prefix,
+        // not a throw; this is why the assertion is `openSpy` called with
+        // `/${scheme}` rather than `expect().toThrow`.
+        expect(calledUrl).toBe(`/${scheme}`);
+        expect(calledUrl).not.toMatch(
+          /^(?:data|vbscript|file|blob|chrome|about|javascript):/i,
+        );
+      });
+    },
+  );
+});
+
+// `redirect` follows the same neutralisation path as `openInNewTab` but
+// goes through `navigateTo`, which catches the assertSafeNavigationUrl
+// throw and falls back to `ensureAppRoot('/')`. Since the schemes above
+// are neutralised by ensureAppRoot rather than throwing, the redirect
+// lands on the prefixed-path-segment — same as window.open above.
+describe('redirect neutralises dangerous schemes by appRoot prefix', () => {
+  let originalLocation: Location;
+  let hrefValue: { value: string };
+
+  beforeEach(() => {
+    originalLocation = window.location;
+    hrefValue = { value: '' };
+    delete (window as unknown as { location?: Location }).location;
+    (window as unknown as { location: Location }).location = {
+      get href() {
+        return hrefValue.value;
+      },
+      set href(next: string) {
+        hrefValue.value = next;
+      },
+      assign(next: string) {
+        hrefValue.value = next;
+      },
+    } as Location;
+  });
+
+  afterEach(() => {
+    (window as unknown as { location: Location }).location = originalLocation;
+  });
+
+  test.each(DANGEROUS_SCHEMES)(
+    'redirect prefixes $label without executing the scheme',
+    async ({ build }: { label: string; build: () => string }) => {
+      const scheme = build();
+      await withApplicationRoot('/superset/', async () => {
+        const { redirect } = await import('src/utils/navigationUtils');
+        redirect(scheme);
+        // The browser would resolve the prefixed path as `<origin>/superset/<scheme:body>`
+        // — a 404 against the static handler, never an executable navigation.
+        //
+        // `navigateTo` runs the path through `new URL(target, BASE).pathname`
+        // then `encodeURI()` as part of the triple-layer CodeQL sanitiser
+        // (`navigationUtils.ts:197-235`). Special chars in the scheme body
+        // are URL-encoded (`<` → `%3C`, then `%` → `%25` on the second
+        // pass), producing a fully inert path. encodeURI is *additional*
+        // neutralisation on top of the scheme-survives-as-path-segment
+        // shape — the open-redirect contract is satisfied by either, but
+        // asserting byte-identity (the prior shape) was wrong because it
+        // predated the encodeURI hardening.
+        const schemePrefix = `${scheme.split(':')[0]}:`;
+        expect(hrefValue.value.startsWith(`/superset/${schemePrefix}`)).toBe(
+          true,
+        );
+        expect(hrefValue.value).not.toMatch(
+          /^(?:data|vbscript|file|blob|chrome|about|javascript):/i,
+        );
+      });
+    },
+  );
+});
+
+// getShareableUrl is the clipboard / "Copy link" path. It must also
+// neutralise — pasting a `data:` link into someone else's chat is the same
+// open-redirect class once the recipient clicks. Pinning here mirrors the
+// directDom test which only covers the imperative-nav helpers.
+describe('getShareableUrl neutralises dangerous schemes', () => {
+  test.each(DANGEROUS_SCHEMES)(
+    'subdir deploy: shareable URL for $label is a prefixed path on this origin',
+    async ({ build }: { label: string; build: () => string }) => {
+      const scheme = build();
+      await withApplicationRoot('/superset/', async () => {
+        const { getShareableUrl } = await import('src/utils/navigationUtils');
+        const url = getShareableUrl(scheme);
+        // Pasted-link recipients land on this origin — not on the
+        // attacker-supplied scheme. getShareableUrl returns the raw
+        // `${origin}${ensureAppRoot(path)}` concatenation, so the shape
+        // is `${origin}/superset/<scheme:body>` byte-for-byte — no URL
+        // parser-driven re-encoding involved.
+        expect(url).toBe(`${window.location.origin}/superset/${scheme}`);
+        expect(url).not.toMatch(
+          /^(?:data|vbscript|file|blob|chrome|about|javascript):/i,
+        );
+      });
+    },
+  );
+});
diff --git a/superset-frontend/src/utils/navigationUtils.test.ts b/superset-frontend/src/utils/navigationUtils.test.ts
index eea2a910843..39f35ee7e8a 100644
--- a/superset-frontend/src/utils/navigationUtils.test.ts
+++ b/superset-frontend/src/utils/navigationUtils.test.ts
@@ -16,48 +16,765 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-jest.mock('./pathUtils', () => ({
-  ensureAppRoot: (url: string) => url,
-}));
-
-let navigateTo: typeof import('./navigationUtils').navigateTo;
-let assignMock: jest.Mock;
-let locationSpy: jest.SpyInstance;
-
-beforeEach(async () => {
-  jest.resetModules();
-  jest.useFakeTimers();
-  ({ navigateTo } = await import('./navigationUtils'));
-  assignMock = jest.fn();
-  locationSpy = jest
-    .spyOn(window, 'location', 'get')
-    .mockReturnValue({ ...window.location, assign: assignMock } as Location);
+import { withApplicationRoot } from 'spec/helpers/withApplicationRoot';
+
+// Surface any future hang as a Jest timeout instead of stalling CI.
+jest.setTimeout(20000);
+
+describe('openInNewTab', () => {
+  let openSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    openSpy.mockRestore();
+  });
+
+  test('passes router-relative path through unchanged when application root is empty', async () => {
+    await withApplicationRoot('', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('/sqllab?new=true');
+      expect(openSpy).toHaveBeenCalledWith(
+        '/sqllab?new=true',
+        '_blank',
+        'noopener noreferrer',
+      );
+    });
+  });
+
+  test('prefixes router-relative path with application root under subdirectory deployment', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('/sqllab?new=true');
+      expect(openSpy).toHaveBeenCalledWith(
+        '/superset/sqllab?new=true',
+        '_blank',
+        'noopener noreferrer',
+      );
+    });
+  });
+
+  test('prefixes correctly for nested subdirectory roots', async () => {
+    await withApplicationRoot('/a/b/c/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('/dashboard/list');
+      expect(openSpy).toHaveBeenCalledWith(
+        '/a/b/c/dashboard/list',
+        '_blank',
+        'noopener noreferrer',
+      );
+    });
+  });
+
+  test('passes absolute URLs through unchanged regardless of application root', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('https://external.example.com/docs');
+      expect(openSpy).toHaveBeenCalledWith(
+        'https://external.example.com/docs',
+        '_blank',
+        'noopener noreferrer',
+      );
+    });
+  });
+
+  test('passes mailto: URLs through unchanged', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('mailto:owner@example.com');
+      expect(openSpy).toHaveBeenCalledWith(
+        'mailto:owner@example.com',
+        '_blank',
+        'noopener noreferrer',
+      );
+    });
+  });
+
+  test('uses noopener noreferrer for security on every call', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      openInNewTab('/sqllab');
+      expect(openSpy).toHaveBeenCalledTimes(1);
+      const features = openSpy.mock.calls[0][2] as string;
+      expect(features).toContain('noopener');
+      expect(features).toContain('noreferrer');
+    });
+  });
+
+  test('refuses protocol-relative URLs to block open-redirect via `//evil.com`', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      expect(() => openInNewTab('//evil.example.com/phish')).toThrow(
+        /refused unsafe URL/,
+      );
+      expect(openSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  // Scheme-without-authority bypass. WHATWG
+  // URL parsers extract the host from `http:evil.com/foo` as `evil.com`,
+  // so the previous regex `/^https?:/i` mistook a cross-origin URL for a
+  // safe absolute one. The tightened regex requires `//` after the scheme.
+  test.each([
+    ['http:evil.example.com/phish'],
+    ['https:evil.example.com/phish'],
+    ['HTTP:evil.example.com/phish'],
+    ['ftp:evil.example.com/file'],
+  ])('refuses scheme-without-authority URL %s', async (unsafeUrl: string) => {
+    await withApplicationRoot('/superset/', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      expect(() => openInNewTab(unsafeUrl)).toThrow(/refused unsafe URL/);
+      expect(openSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  // C0/C1 controls and zero-width / bidi
+  // formatting marks are stripped by browsers before URL parsing, so a
+  // string like `\t//evil.com` would survive the leading-slash check and
+  // navigate cross-origin. Reject them outright.
+  test.each([
+    ['\t//evil.example.com'],
+    ['\n//evil.example.com'],
+    ['\r//evil.example.com'],
+    ['‎/foo'],
+    ['​/foo'],
+    ['/foo'],
+  ])(
+    'refuses URL %j containing control or zero-width chars',
+    async (unsafeUrl: string) => {
+      await withApplicationRoot('/superset/', async () => {
+        const { openInNewTab } = await import('src/utils/navigationUtils');
+        expect(() => openInNewTab(unsafeUrl)).toThrow(/refused unsafe URL/);
+        expect(openSpy).not.toHaveBeenCalled();
+      });
+    },
+  );
+
+  // The bidi formatting
+  // ranges U+202A..U+202E (LRE/RLE/PDF/LRO/RLO) and U+2066..U+2069
+  // (LRI/RLI/FSI/PDI) were promised by the earlier comment but missing from
+  // the original regex. Pin them directly so a documentation/regex drift
+  // can't reintroduce the gap.
+  test.each([
+    ['‪/foo', 'U+202A LRE'],
+    ['‫/foo', 'U+202B RLE'],
+    ['‬/foo', 'U+202C PDF'],
+    ['‭/foo', 'U+202D LRO'],
+    ['‮/foo', 'U+202E RLO'],
+    ['⁦/foo', 'U+2066 LRI'],
+    ['⁧/foo', 'U+2067 RLI'],
+    ['⁨/foo', 'U+2068 FSI'],
+    ['⁩/foo', 'U+2069 PDI'],
+  ])(
+    'refuses URL containing %s bidi formatting mark',
+    async (unsafeUrl: string, _label: string) => {
+      await withApplicationRoot('/superset/', async () => {
+        const { openInNewTab } = await import('src/utils/navigationUtils');
+        expect(() => openInNewTab(unsafeUrl)).toThrow(/refused unsafe URL/);
+        expect(openSpy).not.toHaveBeenCalled();
+      });
+    },
+  );
 });
 
-afterEach(() => {
-  locationSpy.mockRestore();
-  jest.useRealTimers();
+describe('redirect', () => {
+  let originalLocation: Location;
+
+  beforeEach(() => {
+    originalLocation = window.location;
+    delete (window as unknown as { location?: Location }).location;
+    (window as unknown as { location: { href: string } }).location = {
+      href: '',
+    } as Location;
+  });
+
+  afterEach(() => {
+    (window as unknown as { location: Location }).location = originalLocation;
+  });
+
+  test('sets window.location.href to the unprefixed path under empty root', async () => {
+    await withApplicationRoot('', async () => {
+      const { redirect } = await import('src/utils/navigationUtils');
+      redirect('/');
+      expect(window.location.href).toBe('/');
+    });
+  });
+
+  test('prefixes the path under a subdirectory deployment', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { redirect } = await import('src/utils/navigationUtils');
+      redirect('/');
+      expect(window.location.href).toBe('/superset/');
+    });
+  });
+
+  test('passes absolute URLs through unchanged', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { redirect } = await import('src/utils/navigationUtils');
+      redirect('https://external.example.com/foo');
+      expect(window.location.href).toBe('https://external.example.com/foo');
+    });
+  });
+
+  // encodeURI-at-the-sink regression: see the
+  // matching navigateWithState tests — `%27`/`%20` must survive un-doubled.
+  test('does not double-encode percent-encoded query strings', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { redirect } = await import('src/utils/navigationUtils');
+      redirect('/dashboard/list/?filters=(label:%27a%20b%27)');
+      expect(window.location.href).toBe(
+        '/superset/dashboard/list/?filters=(label:%27a%20b%27)',
+      );
+    });
+  });
+
+  // Backslash-laden URLs
+  // were accepted by `SAFE_NAVIGATION_URL_RE` and the sibling guards in
+  // `ensureAppRoot` / `isAllowedScheme`. Browsers normalise `/\` → `//` in
+  // the special-scheme authority, so `/\evil.com` became a cross-origin
+  // navigation. The hardened guard must reject these *before* nav, and
+  // `redirect` (which routes through `navigateTo`) must fall back to `/`
+  // with a `console.error` rather than navigating to the unsafe target.
+
+  test.each([
+    ['empty app root', ''],
+    ['/superset app root', '/superset/'],
+  ])(
+    'falls back to "/" + console.error for /\\evil.com under %s',
+    async (_label, appRoot) => {
+      await withApplicationRoot(appRoot, async () => {
+        const errorSpy = jest
+          .spyOn(console, 'error')
+          .mockImplementation(() => {});
+        try {
+          const { redirect } = await import('src/utils/navigationUtils');
+          redirect('/\\evil.com');
+          // Fall back to ensureAppRoot('/') under the active root.
+          expect(window.location.href).toBe(
+            appRoot === '' ? '/' : '/superset/',
+          );
+          expect(errorSpy).toHaveBeenCalled();
+        } finally {
+          errorSpy.mockRestore();
+        }
+      });
+    },
+  );
+
+  test('falls back to "/" + console.error for https://good@evil.com (userinfo)', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { redirect } = await import('src/utils/navigationUtils');
+        redirect('https://good@evil.example.com/path');
+        expect(window.location.href).toBe('/');
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
+
+  test('falls back to "/" + console.error for //evil.com (already-blocked regression)', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { redirect } = await import('src/utils/navigationUtils');
+        redirect('//evil.example.com');
+        expect(window.location.href).toBe('/');
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
+
+  // Scheme-without-authority bypass on the full-page-nav path. WHATWG URL
+  // parsers extract the host from `http:evil.com/foo` as `evil.com`, so the
+  // external-URL branch's bare `parsed.protocol === 'http:'` check mistook a
+  // cross-origin URL for a safe absolute one. `openInNewTab` already rejected
+  // this shape; `redirect` (→ `navigateTo`) must too, falling back to `/`
+  // with a `console.error` rather than following the cross-origin target.
+  test.each([
+    ['http:evil.example.com/phish'],
+    ['https:evil.example.com/phish'],
+    ['HTTP:evil.example.com/phish'],
+    ['ftp:evil.example.com/file'],
+  ])(
+    'falls back to "/" + console.error for scheme-without-authority URL %s',
+    async (unsafeUrl: string) => {
+      await withApplicationRoot('', async () => {
+        const errorSpy = jest
+          .spyOn(console, 'error')
+          .mockImplementation(() => {});
+        try {
+          const { redirect } = await import('src/utils/navigationUtils');
+          redirect(unsafeUrl);
+          expect(window.location.href).toBe('/');
+          expect(errorSpy).toHaveBeenCalled();
+        } finally {
+          errorSpy.mockRestore();
+        }
+      });
+    },
+  );
 });
 
-test('ignores a repeated assign to the same URL within the dedupe window', () => {
-  navigateTo('/dashboard/new', { assign: true });
-  navigateTo('/dashboard/new', { assign: true });
+// `navigateTo` and `navigateWithState` are exported imperative helpers
+// and are the entry points that these tests cover.
+describe('navigateTo', () => {
+  let originalLocation: Location;
+  let openSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    originalLocation = window.location;
+    delete (window as unknown as { location?: Location }).location;
+    (
+      window as unknown as {
+        location: { href: string; assign: jest.Mock };
+      }
+    ).location = {
+      href: '',
+      assign: jest.fn(),
+    };
+    openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    (window as unknown as { location: Location }).location = originalLocation;
+    openSpy.mockRestore();
+  });
+
+  test('falls back to "/" + console.error for /\\evil.com (default href)', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { navigateTo } = await import('src/utils/navigationUtils');
+        navigateTo('/\\evil.com');
+        expect(window.location.href).toBe('/');
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
+
+  test('falls back to "/" + console.error for /\\evil.com (newWindow path)', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { navigateTo } = await import('src/utils/navigationUtils');
+        navigateTo('/\\evil.com', { newWindow: true });
+        // window.open never called with the unsafe URL; fallback to href='/'
+        expect(openSpy).not.toHaveBeenCalledWith(
+          expect.stringContaining('\\evil.com'),
+          expect.anything(),
+          expect.anything(),
+        );
+        expect(window.location.href).toBe('/');
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
+
+  // Scheme-without-authority bypass: `new URL('http:evil.com/foo')` resolves
+  // the host to `evil.com`, so the external-URL branch must require an
+  // explicit `//` after the scheme (mirroring `SAFE_NAVIGATION_URL_RE`) and
+  // fall back to `/` rather than navigating cross-origin.
+  test.each([
+    ['http:evil.example.com/phish'],
+    ['https:evil.example.com/phish'],
+    ['HTTP:evil.example.com/phish'],
+    ['ftp:evil.example.com/file'],
+  ])(
+    'falls back to "/" + console.error for scheme-without-authority URL %s',
+    async (unsafeUrl: string) => {
+      await withApplicationRoot('', async () => {
+        const errorSpy = jest
+          .spyOn(console, 'error')
+          .mockImplementation(() => {});
+        try {
+          const { navigateTo } = await import('src/utils/navigationUtils');
+          navigateTo(unsafeUrl);
+          expect(window.location.href).toBe('/');
+          expect(openSpy).not.toHaveBeenCalled();
+          expect(errorSpy).toHaveBeenCalled();
+        } finally {
+          errorSpy.mockRestore();
+        }
+      });
+    },
+  );
+
+  test('refuses scheme-without-authority URL on the newWindow path', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { navigateTo } = await import('src/utils/navigationUtils');
+        navigateTo('http:evil.example.com/phish', { newWindow: true });
+        expect(openSpy).not.toHaveBeenCalled();
+        expect(window.location.href).toBe('/');
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
 
-  expect(assignMock).toHaveBeenCalledTimes(1);
-  expect(assignMock).toHaveBeenCalledWith('/dashboard/new');
+  test('passes legitimate router-relative path through unchanged', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('/dashboard/list/');
+      expect(window.location.href).toBe('/superset/dashboard/list/');
+    });
+  });
+
+  test('uses window.location.assign (not href) for a router-relative path when { assign: true }', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('/dashboard/list/', { assign: true });
+      expect(window.location.assign).toHaveBeenCalledWith(
+        '/superset/dashboard/list/',
+      );
+      // The assign branch must not also fall through to the href sink.
+      expect(window.location.href).toBe('');
+    });
+  });
+
+  test('uses window.location.assign for an external URL when { assign: true }', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('https://external.example.com/docs', { assign: true });
+      expect(window.location.assign).toHaveBeenCalledWith(
+        'https://external.example.com/docs',
+      );
+      expect(window.location.href).toBe('');
+    });
+  });
+});
+
+describe('navigateWithState', () => {
+  let pushSpy: jest.SpyInstance;
+  let replaceSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    pushSpy = jest
+      .spyOn(window.history, 'pushState')
+      .mockImplementation(() => {});
+    replaceSpy = jest
+      .spyOn(window.history, 'replaceState')
+      .mockImplementation(() => {});
+  });
+
+  afterEach(() => {
+    pushSpy.mockRestore();
+    replaceSpy.mockRestore();
+  });
+
+  test('no-ops + console.error for /\\evil.com (does NOT trigger full-page nav)', async () => {
+    await withApplicationRoot('', async () => {
+      const errorSpy = jest
+        .spyOn(console, 'error')
+        .mockImplementation(() => {});
+      try {
+        const { navigateWithState } = await import('src/utils/navigationUtils');
+        navigateWithState('/\\evil.com', { from: 'test' });
+        expect(pushSpy).not.toHaveBeenCalled();
+        expect(replaceSpy).not.toHaveBeenCalled();
+        expect(errorSpy).toHaveBeenCalled();
+      } finally {
+        errorSpy.mockRestore();
+      }
+    });
+  });
+
+  // Scheme-without-authority bypass on the history-API path: the external-URL
+  // branch must reject `http:evil.com/foo` (host normalises to `evil.com`)
+  // and no-op rather than pushing a cross-origin URL into history.
+  test.each([
+    ['http:evil.example.com/phish'],
+    ['https:evil.example.com/phish'],
+    ['HTTP:evil.example.com/phish'],
+    ['ftp:evil.example.com/file'],
+  ])(
+    'no-ops + console.error for scheme-without-authority URL %s',
+    async (unsafeUrl: string) => {
+      await withApplicationRoot('', async () => {
+        const errorSpy = jest
+          .spyOn(console, 'error')
+          .mockImplementation(() => {});
+        try {
+          const { navigateWithState } =
+            await import('src/utils/navigationUtils');
+          navigateWithState(unsafeUrl, { from: 'test' });
+          expect(pushSpy).not.toHaveBeenCalled();
+          expect(replaceSpy).not.toHaveBeenCalled();
+          expect(errorSpy).toHaveBeenCalled();
+        } finally {
+          errorSpy.mockRestore();
+        }
+      });
+    },
+  );
+
+  test('passes legitimate router-relative path through to pushState', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/42/', { from: 'test' });
+      expect(pushSpy).toHaveBeenCalledWith(
+        { from: 'test' },
+        '',
+        '/superset/dashboard/42/',
+      );
+    });
+  });
+
+  test('uses replaceState (not pushState) when { replace: true }', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/42/', { from: 'test' }, { replace: true });
+      expect(replaceSpy).toHaveBeenCalledWith(
+        { from: 'test' },
+        '',
+        '/superset/dashboard/42/',
+      );
+      expect(pushSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  // encodeURI-at-the-sink regression: the CodeQL
+  // `js/html-injection` sanitiser escapes `%` itself, so already-percent-
+  // encoded paths (the URL constructor's output always is) were double-
+  // encoded — `%20` → `%2520` — corrupting the pushed URL.
+  test('does not double-encode percent-encoded rison paths (pushState)', async () => {
+    await withApplicationRoot('', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/list/?filters=(label:%27a%20b%27)', {});
+      expect(pushSpy).toHaveBeenCalledWith(
+        {},
+        '',
+        '/dashboard/list/?filters=(label:%27a%20b%27)',
+      );
+    });
+  });
+
+  test('single-encodes raw spaces and unicode path segments (pushState)', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/百分比 slug/', {});
+      expect(pushSpy).toHaveBeenCalledWith(
+        {},
+        '',
+        '/superset/dashboard/%E7%99%BE%E5%88%86%E6%AF%94%20slug/',
+      );
+    });
+  });
+
+  // Restore-regex corner cases (review follow-up on the encodeURI fix):
+  // pinned so a future "simplification" of DOUBLE_ENCODED_ESCAPE_RE (e.g.
+  // an uppercase-only hex class, or a greedy restore that collapses
+  // intentional `%25XX`) cannot pass the suite while corrupting URLs.
+  test('preserves intentionally double-encoded %2520 (pushState)', async () => {
+    await withApplicationRoot('', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/list/?q=%2520', {});
+      expect(pushSpy).toHaveBeenCalledWith({}, '', '/dashboard/list/?q=%2520');
+    });
+  });
+
+  test('preserves literal %25 followed by hex (%25AB) (pushState)', async () => {
+    await withApplicationRoot('', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/list/?q=%25AB', {});
+      expect(pushSpy).toHaveBeenCalledWith({}, '', '/dashboard/list/?q=%25AB');
+    });
+  });
+
+  test('preserves lowercase hex escapes (%2f) (pushState)', async () => {
+    await withApplicationRoot('', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/a%2fb/', {});
+      expect(pushSpy).toHaveBeenCalledWith({}, '', '/dashboard/a%2fb/');
+    });
+  });
+
+  test('single-encodes an invalid percent sequence (%zz) (pushState)', async () => {
+    await withApplicationRoot('', async () => {
+      const { navigateWithState } = await import('src/utils/navigationUtils');
+      navigateWithState('/dashboard/100%zz/', {});
+      expect(pushSpy).toHaveBeenCalledWith({}, '', '/dashboard/100%25zz/');
+    });
+  });
 });
 
-test('assigns different URLs in quick succession', () => {
-  navigateTo('/dashboard/new', { assign: true });
-  navigateTo('/chart/add', { assign: true });
+describe('getShareableUrl', () => {
+  test('returns origin + unprefixed path under empty root', async () => {
+    await withApplicationRoot('', async () => {
+      const { getShareableUrl } = await import('src/utils/navigationUtils');
+      expect(getShareableUrl('/sqllab?id=1')).toBe(
+        `${window.location.origin}/sqllab?id=1`,
+      );
+    });
+  });
+
+  test('returns origin + prefixed path under subdirectory deployment', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { getShareableUrl } = await import('src/utils/navigationUtils');
+      expect(getShareableUrl('/sqllab?id=1')).toBe(
+        `${window.location.origin}/superset/sqllab?id=1`,
+      );
+    });
+  });
+
+  // Composition pin:
+  // `assertSafeNavigationUrl` is module-private; route the pin through the
+  // purest public helper. `getShareableUrl` runs the same composition
+  // (`assertSafeNavigationUrl(ensureAppRoot(...))`) as `openInNewTab` /
+  // `AppLink`, side-effect-free. Throwing here proves the guard runs
+  // post-`ensureAppRoot`, which is the invariant `pathUtils.ts:41` must
+  // preserve in lockstep.
+  test.each([
+    ['empty app root', ''],
+    ['/superset app root', '/superset/'],
+  ])(
+    'throws for /\\evil.com under %s (composition pin)',
+    async (_label, appRoot) => {
+      await withApplicationRoot(appRoot, async () => {
+        const { getShareableUrl } = await import('src/utils/navigationUtils');
+        expect(() => getShareableUrl('/\\evil.com')).toThrow(
+          /refused unsafe URL/,
+        );
+      });
+    },
+  );
+
+  test('throws for //evil.com (already-blocked regression)', async () => {
+    await withApplicationRoot('', async () => {
+      const { getShareableUrl } = await import('src/utils/navigationUtils');
+      expect(() => getShareableUrl('//evil.example.com')).toThrow(
+        /refused unsafe URL/,
+      );
+    });
+  });
 
-  expect(assignMock).toHaveBeenCalledTimes(2);
+  test('throws for https://good@evil.com (userinfo)', async () => {
+    await withApplicationRoot('', async () => {
+      const { getShareableUrl } = await import('src/utils/navigationUtils');
+      expect(() =>
+        getShareableUrl('https://good@evil.example.com/path'),
+      ).toThrow(/refused unsafe URL/);
+    });
+  });
 });
 
-test('assigns the same URL again once the dedupe window has elapsed', () => {
-  navigateTo('/dashboard/new', { assign: true });
-  jest.advanceTimersByTime(1000);
-  navigateTo('/dashboard/new', { assign: true });
+describe('openInNewTab — sibling-site coverage', () => {
+  let openSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    openSpy.mockRestore();
+  });
+
+  test.each([
+    ['empty app root', ''],
+    ['/superset app root', '/superset/'],
+  ])(
+    'throws for /\\evil.com under %s and never calls window.open',
+    async (_label, appRoot) => {
+      await withApplicationRoot(appRoot, async () => {
+        const { openInNewTab } = await import('src/utils/navigationUtils');
+        expect(() => openInNewTab('/\\evil.com')).toThrow(/refused unsafe URL/);
+        expect(openSpy).not.toHaveBeenCalled();
+      });
+    },
+  );
+
+  test('throws for https://good@evil.com (userinfo)', async () => {
+    await withApplicationRoot('', async () => {
+      const { openInNewTab } = await import('src/utils/navigationUtils');
+      expect(() => openInNewTab('https://good@evil.example.com')).toThrow(
+        /refused unsafe URL/,
+      );
+      expect(openSpy).not.toHaveBeenCalled();
+    });
+  });
+});
+
+// AppLink renders a real React element, so its tests can't use
+// withApplicationRoot — `jest.resetModules()` corrupts @testing-library/react
+// when its dist files are re-imported across the reset. Mock applicationRoot
+// at module scope and vary it per test instead.
+//
+// Note: the mock factory is hoisted, so `mockApplicationRoot` must be
+// `mock`-prefixed to satisfy Jest's out-of-scope-variable check.
+
+// Duplicate-assign suppression (master PR #40833): a double-click that fires
+// two identical `navigateTo(..., { assign: true })` calls within 1s must reach
+// `window.location.assign` only once. The dedupe is keyed on the final,
+// app-root-prefixed sink value. These exercise the real `navigateTo` (not an
+// identity stub) so app-root prefixing and the assign barrier run end to end.
+describe('navigateTo duplicate-assign suppression', () => {
+  let originalLocation: Location;
+  let assignMock: jest.Mock;
+
+  beforeEach(() => {
+    jest.useFakeTimers();
+    originalLocation = window.location;
+    delete (window as unknown as { location?: Location }).location;
+    assignMock = jest.fn();
+    (
+      window as unknown as { location: { href: string; assign: jest.Mock } }
+    ).location = { href: '', assign: assignMock };
+  });
+
+  afterEach(() => {
+    (window as unknown as { location: Location }).location = originalLocation;
+    jest.useRealTimers();
+  });
+
+  test('ignores a repeated assign to the same URL within the dedupe window', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('/dashboard/new/', { assign: true });
+      navigateTo('/dashboard/new/', { assign: true });
+      expect(assignMock).toHaveBeenCalledTimes(1);
+      expect(assignMock).toHaveBeenCalledWith('/superset/dashboard/new/');
+    });
+  });
+
+  test('assigns different URLs in quick succession', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('/dashboard/new/', { assign: true });
+      navigateTo('/chart/add/', { assign: true });
+      expect(assignMock).toHaveBeenCalledTimes(2);
+    });
+  });
 
-  expect(assignMock).toHaveBeenCalledTimes(2);
+  test('assigns the same URL again once the dedupe window has elapsed', async () => {
+    await withApplicationRoot('/superset/', async () => {
+      const { navigateTo } = await import('src/utils/navigationUtils');
+      navigateTo('/dashboard/new/', { assign: true });
+      jest.advanceTimersByTime(1000);
+      navigateTo('/dashboard/new/', { assign: true });
+      expect(assignMock).toHaveBeenCalledTimes(2);
+    });
+  });
 });
diff --git a/superset-frontend/src/utils/navigationUtils.ts b/superset-frontend/src/utils/navigationUtils.ts
index 967de1fc24c..5ee26e12a68 100644
--- a/superset-frontend/src/utils/navigationUtils.ts
+++ b/superset-frontend/src/utils/navigationUtils.ts
@@ -16,48 +16,476 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { sanitizeUrl } from '@braintree/sanitize-url';
-import { ensureAppRoot } from './pathUtils';
+import {
+  createElement,
+  type AnchorHTMLAttributes,
+  type ReactElement,
+} from 'react';
+import { ensureAppRoot, makeUrl, stripAppRoot } from './pathUtils';
+// Note: master PR #40546 added `import { sanitizeUrl } from
+// '@braintree/sanitize-url';` here as part of its sink-wrapping approach.
+// Dropped on merge because this file's `assertSafeNavigationUrl` /
+// inline-barrier scheme is strictly stronger: it rejects `javascript:` /
+// `data:` (scheme allowlist), backslash-laden authority spoofing,
+// and userinfo-bearing http(s)/ftp URLs — none of which
+// `sanitizeUrl` rejects. The library remains a project dep (used in
+// ListViewCard, SqlAlchemyForm, etc.) and is intentionally unused here.
 
+// Re-export so callers that legitimately need a raw prefixed path (native
+// fetch, navigator.sendBeacon, image src, third-party `href` props) have a
+// single sanctioned import location. The static-invariant scan disallows
+// importing from `pathUtils` directly outside this module.
+export { ensureAppRoot, makeUrl, stripAppRoot };
+
+// The guard helpers are declared before `navigateTo` / `navigateWithState`
+// so oxlint's no-use-before-define lint (which does not honour function-
+// declaration hoisting) does not fire on the wired-up imperative-nav
+// path. The focused helpers below (`openInNewTab`, `getShareableUrl`,
+// `AppLink`) also reach for `assertSafeNavigationUrl` directly.
+
+const NEW_TAB_FEATURES = 'noopener noreferrer';
+
+// Duplicate-navigation suppression for `assign`-mode navigations (master
+// PR #40833: prevents a double-click on "create dashboard" from firing two
+// identical `window.location.assign` calls and creating duplicate objects).
+// Keyed on the final, already-sanitised sink value so the dedupe decision is
+// made on exactly what would reach `window.location.assign`. This is pure
+// bookkeeping — it never touches the sink, so the inline CodeQL barriers on
+// each `assign` branch below remain intact.
 const DUPLICATE_NAV_WINDOW_MS = 1000;
 let lastAssignUrl: string | null = null;
 let lastAssignAt = 0;
 
-export const navigateTo = (
+function shouldSkipDuplicateAssign(sinkValue: string): boolean {
+  const now = Date.now();
+  if (
+    lastAssignUrl === sinkValue &&
+    now - lastAssignAt < DUPLICATE_NAV_WINDOW_MS
+  ) {
+    return true;
+  }
+  lastAssignUrl = sinkValue;
+  lastAssignAt = now;
+  return false;
+}
+
+// Constant base for the URL-constructor barrier in the router-relative
+// branches below. Using a constant (rather than `window.location.origin`)
+// keeps the parse fully deterministic — test mocks that supply only
+// `window.location.href` still exercise the same code path, and CodeQL
+// recognises the literal-string origin equality as a sanitiser.
+const SAFE_PATH_PARSE_BASE = 'http://navigation-utils.invalid/';
+const SAFE_PATH_PARSE_ORIGIN = 'http://navigation-utils.invalid';
+
+// Allow-list of safe URL shapes for navigation: router-relative paths and a
+// small set of known-safe schemes. `ensureAppRoot` already neutralises
+// `javascript:` / `data:` by prefixing them as relative paths; protocol-
+// relative `//host` is intentionally excluded here because it is a cross-
+// origin navigation primitive that previously enabled open redirects.
+//
+// The leading-slash branch also rejects any URL
+// containing a backslash anywhere — browsers normalise `/\evil.com` →
+// `//evil.com` in the special-scheme authority, so backslashes in any
+// position let an attacker craft a path that looks router-relative until
+// the browser parses it. http(s)/ftp URLs with userinfo (`@` before the
+// first `/` after `//`) are rejected by the post-regex authority check
+// below, since `https://good@evil.com` resolves to the host `evil.com`
+// despite presenting as a same-origin-looking URL to the eye.
+//
+// The http(s)/ftp branches require an
+// explicit `//` after the scheme. Without it, `new URL('http:evil.com')`
+// in modern browsers parses the authority as `evil.com` — a same-origin-
+// looking absolute URL that resolves cross-origin. Mirroring the
+// protocol-relative `//host` reject from above.
+// URL strings containing C0/C1 controls
+// (`\t`/`\n`/`\r`/etc., DEL, U+0080..U+009F) or Unicode zero-width / bidi
+// formatting marks (U+200B..U+200F, U+202A..U+202E, U+2066..U+2069,
+// U+2028, U+2029, U+FEFF) are rejected outright. Browsers strip leading
+// C0 controls before parsing the leading-slash branch, so `\t//evil.com`
+// would otherwise leave the leading-slash check unable to see the
+// protocol-relative `//`. Defence-in-depth — no known active bypass
+// against the current allow-list, but the regex would otherwise depend
+// on browser-specific normalisation behaviour.
+//
+// The bidi range was
+// extended to cover the explicit-direction format characters U+202A..E
+// (LRE/RLE/PDF/LRO/RLO) and U+2066..9 (LRI/RLI/FSI/PDI). WHATWG strips
+// these before parsing the host, but the earlier comment had promised them
+// while the regex only covered U+200E/F — closing the documentation
+// drift the code review surfaced.
+const SAFE_NAVIGATION_URL_RE =
+  /^(?:\/(?!\/)|https?:\/\/|ftp:\/\/|mailto:|tel:)/i;
+const USERINFO_BEARING_SCHEME_RE = /^(?:https?|ftp):\/\//i;
+// String-literal `\u` escapes (via `new RegExp(...)`) instead of a regex
+// literal so source-file tooling (Babel parser, prettier, eslint) does
+// not have to round-trip raw bidi / zero-width / line-separator bytes.
+const FORBIDDEN_CONTROL_CHARS_RE = new RegExp(
+  '[\\x00-\\x1F\\x7F-\\x9F' +
+    '\\u200B-\\u200F' +
+    '\\u202A-\\u202E' +
+    '\\u2066-\\u2069' +
+    '\\u2028\\u2029\\uFEFF]',
+);
+
+// `encodeURI` (kept at the sinks below as CodeQL's recognised
+// `js/html-injection` sanitiser) escapes `%` itself, so an already-
+// percent-encoded path — and `parsed.pathname`/`.search`/`.hash` from the
+// URL constructor always are — would be double-encoded (`%20` → `%2520`)
+// and the navigation would land on the wrong URL. Mapping `%25XX`
+// (XX = hex) back to `%XX` after the encodeURI pass restores the
+// constructor's escape sequences. The pair is NOT a strict no-op: the
+// WHATWG serialiser emits some characters raw that encodeURI escapes
+// (`|`, `^`, `[`, `]` in paths; also `{`, `}`, backtick in queries), so
+// those end up single-encoded — semantically equivalent, since servers
+// percent-decode them back to the same resource.
+const DOUBLE_ENCODED_ESCAPE_RE = /%25([0-9A-Fa-f]{2})/g;
+
+function assertSafeNavigationUrl(url: string): string {
+  if (FORBIDDEN_CONTROL_CHARS_RE.test(url)) {
+    throw new Error(
+      'navigationUtils refused unsafe URL: contains a forbidden ' +
+        'control or zero-width character (would survive the allow-list ' +
+        'as a browser-normalised navigation primitive).',
+    );
+  }
+  if (!SAFE_NAVIGATION_URL_RE.test(url) || url.includes('\\')) {
+    throw new Error(
+      'navigationUtils refused unsafe URL: only relative paths and ' +
+        'http(s)://, ftp://, mailto:, tel: schemes are allowed.',
+    );
+  }
+  if (USERINFO_BEARING_SCHEME_RE.test(url)) {
+    let parsed: URL;
+    try {
+      parsed = new URL(url);
+    } catch {
+      throw new Error(
+        'navigationUtils refused unsafe URL: unparseable authority.',
+      );
+    }
+    if (parsed.username || parsed.password) {
+      throw new Error(
+        'navigationUtils refused unsafe URL: ' +
+          'http(s)/ftp URLs with userinfo are not allowed.',
+      );
+    }
+  }
+  return url;
+}
+
+/**
+ * Imperative full-page navigation. Internal entry point for `redirect()`
+ * and a handful of legacy callers; new code should prefer `<AppLink>` or
+ * `redirect()`. Unsafe URLs (protocol-relative, backslash-laden, userinfo-
+ * carrying http(s)) fall back to `ensureAppRoot('/')` with a `console.error`
+ * — never a silent navigation to the rejected target.
+ *
+ * Each `window.*` sink lives inside an if-block whose guard layers
+ * sanitisers so every CodeQL query family that fires on these sinks sees
+ * one it recognises:
+ *
+ *   1. `target.startsWith('/')` + `!target.startsWith('//')` +
+ *      `!target.includes('\\')` — recognised by `js/client-side-
+ *      unvalidated-url-redirection`; rules out protocol-relative and
+ *      browser-normalised backslash-laden authority spoofing.
+ *   2. `new URL(target, SAFE_PATH_PARSE_BASE)` + literal equality on
+ *      `parsed.origin` + literal equality on `parsed.protocol` —
+ *      recognised by `js/xss-through-dom`. The constant base keeps the
+ *      parse deterministic when test mocks supply only `window.location.
+ *      { href, assign }` and not `origin`.
+ *   3. `safePath.startsWith('/')` + `!safePath.startsWith('//')` +
+ *      `!safePath.includes('\\')` — extra defence-in-depth on the value
+ *      actually flowing to the sink.
+ *   4. `encodeURI(safePath)` at the sink itself — `js/html-injection`'s
+ *      default model only recognises specific through-function sanitisers
+ *      (`encodeURI`, `encodeURIComponent`, `DOMPurify.sanitize`, …) and
+ *      does NOT propagate startsWith/equality barriers from `target` or
+ *      `parsed.*` through the URL-property derivation into the assigned
+ *      string. Because `encodeURI` escapes `%` itself (it is NOT
+ *      idempotent on percent-encoded input), each sink restores the
+ *      `%XX` escape sequences afterwards via DOUBLE_ENCODED_ESCAPE_RE.
+ *      Characters the WHATWG serialiser emits raw but encodeURI escapes
+ *      (`|^[]{}` and backtick) stay single-encoded — semantically
+ *      equivalent, not byte-identical (see DOUBLE_ENCODED_ESCAPE_RE).
+ *
+ * External-URL paths get their own block with `URL.protocol` literal-
+ * equality + userinfo guards on the URL-constructor's normalised `href`.
+ *
+ * @internal
+ */
+export function navigateTo(
   url: string,
   options?: { newWindow?: boolean; assign?: boolean },
-) => {
-  if (options?.newWindow) {
-    window.open(
-      sanitizeUrl(ensureAppRoot(url)),
-      '_blank',
-      'noopener noreferrer',
-    );
-  } else if (options?.assign) {
-    const sanitized = sanitizeUrl(ensureAppRoot(url));
-    const now = Date.now();
+): void {
+  const target = ensureAppRoot(url);
+  // Router-relative fast-path: triple-layer barrier so every CodeQL query
+  // family that fires on `window.location.*` / `window.history.*` sees a
+  // recognised sanitiser:
+  //   1. Outer `startsWith('/')` + `!startsWith('//')` + `!includes('\\')`
+  //      on `target` — recognised by `js/client-side-unvalidated-url-
+  //      redirection` and rules out protocol-relative + backslash-laden
+  //      authority-spoofing.
+  //   2. URL-constructor parse + literal-equality on `parsed.origin` and
+  //      `parsed.protocol` — recognised by `js/xss-through-dom`. The
+  //      constant base (`http://navigation-utils.invalid/`) keeps the parse
+  //      deterministic under test mocks.
+  //   3. Inline `startsWith('/')` + `!startsWith('//')` + `!includes('\\')`
+  //      on `safePath` — the value actually fed to the sink. Required for
+  //      `js/html-injection`, whose default model does NOT propagate
+  //      sanitisers from `target` through the URL-property derivation
+  //      (`parsed.pathname` etc.) into the assigned string.
+  if (
+    target.startsWith('/') &&
+    !target.startsWith('//') &&
+    !target.includes('\\')
+  ) {
+    let parsed: URL | null;
+    try {
+      parsed = new URL(target, SAFE_PATH_PARSE_BASE);
+    } catch {
+      parsed = null;
+    }
+    if (
+      parsed !== null &&
+      parsed.origin === SAFE_PATH_PARSE_ORIGIN &&
+      parsed.protocol === 'http:'
+    ) {
+      const safePath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
+      if (
+        safePath.startsWith('/') &&
+        !safePath.startsWith('//') &&
+        !safePath.includes('\\')
+      ) {
+        // `encodeURI` is CodeQL's built-in sanitiser for `js/html-injection`
+        // on `window.location.*` sinks. The previous startsWith barriers
+        // satisfy `js/url-redirection` and `js/xss-through-dom`, but
+        // `js/html-injection`'s default model only recognises specific
+        // through-function sanitisers (`encodeURI`, `encodeURIComponent`,
+        // `DOMPurify.sanitize`, …). `encodeURI` escapes `%` itself, which
+        // would double-encode the constructor-normalised path, so the
+        // follow-up replace restores the `%XX` escape sequences (see
+        // DOUBLE_ENCODED_ESCAPE_RE) — net effect: a runtime no-op for
+        // legitimate paths with the sanitiser still visible at the sink.
+        const sinkValue = encodeURI(safePath).replace(
+          DOUBLE_ENCODED_ESCAPE_RE,
+          '%$1',
+        );
+        if (options?.newWindow) {
+          window.open(sinkValue, '_blank', NEW_TAB_FEATURES);
+        } else if (options?.assign) {
+          if (!shouldSkipDuplicateAssign(sinkValue)) {
+            window.location.assign(sinkValue);
+          }
+        } else {
+          window.location.href = sinkValue;
+        }
+        return;
+      }
+    }
+  }
+  // External-URL path: parse the input, verify the scheme is in our
+  // allowlist via literal equality (CodeQL recognises constant-equality
+  // barriers on `URL.protocol`), verify no userinfo, then feed the URL
+  // constructor's normalised `href` to the sink. `parsed.href` is a fresh
+  // string derived from the verified URL properties, so the data-flow
+  // chain from `url` (source) to the sink is broken by the parse +
+  // property-check pair.
+  // `SAFE_NAVIGATION_URL_RE` requires an explicit `//` after http(s)/ftp,
+  // mirroring `assertSafeNavigationUrl`. Without it, `new URL('http:evil.com')`
+  // parses the authority as `evil.com` — a cross-origin URL masquerading as a
+  // safe absolute one. The protocol-equality check below alone would accept it
+  // because `parsed.protocol` is still `http:` after normalisation.
+  let parsed: URL | null;
+  try {
+    parsed = new URL(target);
+  } catch {
+    parsed = null;
+  }
+  if (
+    parsed !== null &&
+    SAFE_NAVIGATION_URL_RE.test(target) &&
+    (parsed.protocol === 'https:' ||
+      parsed.protocol === 'http:' ||
+      parsed.protocol === 'ftp:' ||
+      parsed.protocol === 'mailto:' ||
+      parsed.protocol === 'tel:') &&
+    !parsed.username &&
+    !parsed.password
+  ) {
+    const safeExternal = parsed.href;
+    if (options?.newWindow) {
+      window.open(safeExternal, '_blank', NEW_TAB_FEATURES);
+    } else if (options?.assign) {
+      if (!shouldSkipDuplicateAssign(safeExternal)) {
+        window.location.assign(safeExternal);
+      }
+    } else {
+      window.location.href = safeExternal;
+    }
+    return;
+  }
+  // eslint-disable-next-line no-console -- guarded surface, observable in dev.
+  console.error('navigationUtils.navigateTo refused unsafe URL:', url);
+  // Fallback to deployment home. Same triple-layer barrier so CodeQL sees
+  // the sanitiser at this sink too (see `navigateTo` docstring).
+  const home = ensureAppRoot('/');
+  if (home.startsWith('/') && !home.startsWith('//') && !home.includes('\\')) {
+    let homeUrl: URL | null;
+    try {
+      homeUrl = new URL(home, SAFE_PATH_PARSE_BASE);
+    } catch {
+      homeUrl = null;
+    }
     if (
-      lastAssignUrl === sanitized &&
-      now - lastAssignAt < DUPLICATE_NAV_WINDOW_MS
+      homeUrl !== null &&
+      homeUrl.origin === SAFE_PATH_PARSE_ORIGIN &&
+      homeUrl.protocol === 'http:'
     ) {
-      return;
+      const safeHome = `${homeUrl.pathname}${homeUrl.search}${homeUrl.hash}`;
+      if (
+        safeHome.startsWith('/') &&
+        !safeHome.startsWith('//') &&
+        !safeHome.includes('\\')
+      ) {
+        // See router-relative branch above for `encodeURI` rationale.
+        window.location.href = encodeURI(safeHome).replace(
+          DOUBLE_ENCODED_ESCAPE_RE,
+          '%$1',
+        );
+      }
     }
-    lastAssignUrl = sanitized;
-    lastAssignAt = now;
-    window.location.assign(sanitized);
-  } else {
-    window.location.href = sanitizeUrl(ensureAppRoot(url));
   }
-};
+}
 
-export const navigateWithState = (
+/**
+ * Imperative history-API navigation (no page load). Unsafe URLs become a
+ * silent no-op + `console.error`; we deliberately do NOT fall back to a
+ * full-page nav here because callers (e.g. dashboard-properties save)
+ * already display their own success/failure feedback and a surprise
+ * `window.location.href = '/'` would discard unsaved state.
+ *
+ * Inline barriers mirror `navigateTo` (see its docstring) so CodeQL
+ * recognises the sanitiser at every `window.history.*` sink.
+ *
+ * @internal
+ */
+export function navigateWithState(
   url: string,
   state: Record<string, unknown>,
   options?: { replace?: boolean },
-) => {
-  if (options?.replace) {
-    window.history.replaceState(state, '', ensureAppRoot(url));
-  } else {
-    window.history.pushState(state, '', ensureAppRoot(url));
+): void {
+  const target = ensureAppRoot(url);
+  // Router-relative fast-path (see `navigateTo` for full rationale on the
+  // triple-layer barrier: outer `startsWith` triple on `target`, URL-
+  // constructor parse with literal origin/protocol equality, and inline
+  // `startsWith` triple on `safePath` itself for `js/html-injection`).
+  if (
+    target.startsWith('/') &&
+    !target.startsWith('//') &&
+    !target.includes('\\')
+  ) {
+    let parsed: URL | null;
+    try {
+      parsed = new URL(target, SAFE_PATH_PARSE_BASE);
+    } catch {
+      parsed = null;
+    }
+    if (
+      parsed !== null &&
+      parsed.origin === SAFE_PATH_PARSE_ORIGIN &&
+      parsed.protocol === 'http:'
+    ) {
+      const safePath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
+      if (
+        safePath.startsWith('/') &&
+        !safePath.startsWith('//') &&
+        !safePath.includes('\\')
+      ) {
+        // See `navigateTo` for `encodeURI` rationale.
+        const sinkValue = encodeURI(safePath).replace(
+          DOUBLE_ENCODED_ESCAPE_RE,
+          '%$1',
+        );
+        if (options?.replace) {
+          window.history.replaceState(state, '', sinkValue);
+        } else {
+          window.history.pushState(state, '', sinkValue);
+        }
+        return;
+      }
+    }
+  }
+  // External-URL path (see `navigateTo` for rationale, including the
+  // `SAFE_NAVIGATION_URL_RE` `//`-after-scheme requirement that rejects
+  // `http:evil.com`-style scheme-without-authority bypasses).
+  let parsed: URL | null;
+  try {
+    parsed = new URL(target);
+  } catch {
+    parsed = null;
+  }
+  if (
+    parsed !== null &&
+    SAFE_NAVIGATION_URL_RE.test(target) &&
+    (parsed.protocol === 'https:' ||
+      parsed.protocol === 'http:' ||
+      parsed.protocol === 'ftp:' ||
+      parsed.protocol === 'mailto:' ||
+      parsed.protocol === 'tel:') &&
+    !parsed.username &&
+    !parsed.password
+  ) {
+    const safeExternal = parsed.href;
+    if (options?.replace) {
+      window.history.replaceState(state, '', safeExternal);
+    } else {
+      window.history.pushState(state, '', safeExternal);
+    }
+    return;
   }
-};
+  // eslint-disable-next-line no-console -- guarded surface, observable in dev.
+  console.error('navigationUtils.navigateWithState refused unsafe URL:', url);
+}
+
+/** Open a router-relative path in a new browser tab. */
+export function openInNewTab(path: string): void {
+  window.open(
+    assertSafeNavigationUrl(ensureAppRoot(path)),
+    '_blank',
+    NEW_TAB_FEATURES,
+  );
+}
+
+/**
+ * Full-page redirect to a router-relative path. Use only when the destination
+ * is outside the React Router tree or a hard reload is required.
+ *
+ * Failure mode differs from the throwing helpers (`openInNewTab`,
+ * `getShareableUrl`, `AppLink`): this delegates to `navigateTo`, which on an
+ * unsafe URL does NOT throw — it soft-falls back to `ensureAppRoot('/')` and
+ * logs via `console.error`. Prefer a throwing helper when the caller must
+ * observe a rejected navigation.
+ */
+export function redirect(path: string): void {
+  navigateTo(path);
+}
+
+/** Build a `${origin}${appRoot}${path}` URL for clipboard / share targets. */
+export function getShareableUrl(path: string): string {
+  const safePath = assertSafeNavigationUrl(ensureAppRoot(path));
+  return `${window.location.origin}${safePath}`;
+}
+
+/**
+ * Anchor element that prefixes its href with the application root. Use
+ * instead of `<a href={varExpr}>` whenever the href is computed at runtime.
+ */
+export function AppLink(
+  props: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string },
+): ReactElement {
+  const { href, ...rest } = props;
+  return createElement('a', {
+    ...rest,
+    href: assertSafeNavigationUrl(ensureAppRoot(href)),
+  });
+}
diff --git a/superset-frontend/src/utils/pathUtils.parity.test.ts b/superset-frontend/src/utils/pathUtils.parity.test.ts
new file mode 100644
index 00000000000..ac8aa9734a6
--- /dev/null
+++ b/superset-frontend/src/utils/pathUtils.parity.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// `pathUtils.ensureAppRoot` and `SupersetClientClass.getUrl` are the two
+// runtime safety nets beneath the Layer-2 static scan (see memory
+// `project_supersetclient_approot_dedupe`). They run on different inputs —
+// one prefixes app-relative paths in component code, the other strips a
+// pre-prefixed endpoint at the HTTP boundary — but the dedupe contract is
+// identical: a pre-prefixed input must not produce `/superset/superset/...`.
+//
+// The existing idempotence tests cover the happy path
+// (`ensureAppRoot('/superset/sqllab') === '/superset/sqllab'`). These tests
+// pin the trickier edge cases where bare-root variants and query/hash
+// fragments could plausibly trip the dedupe logic — and document the cases
+// where ensureAppRoot deliberately does NOT strip (root with embedded
+// query / hash, since the input is ambiguous about whether the leading
+// `/superset` is the app root or a literal path segment).
+
+import { SupersetClientClass } from '@superset-ui/core';
+
+async function loadEnsureAppRoot(
+  appRoot = '/superset',
+): Promise<(path: string) => string> {
+  const bootstrap = { common: { application_root: appRoot } };
+  document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(
+    bootstrap,
+  )}'></div>`;
+  jest.resetModules();
+  await import('./getBootstrapData');
+  const { ensureAppRoot } = await import('./pathUtils');
+  return ensureAppRoot;
+}
+
+function clientFor(appRoot: string): SupersetClientClass {
+  // Note: appRoot ends WITHOUT a trailing slash, mirroring the bootstrap
+  // payload contract (`applicationRoot()` strips trailing slash).
+  return new SupersetClientClass({
+    appRoot,
+    protocol: 'https:',
+    host: 'example.com',
+  });
+}
+
+afterEach(() => {
+  document.body.innerHTML = '';
+  jest.resetModules();
+});
+
+// ---------------------------------------------------------------------------
+// Edge cases: bare root, root with trailing slash, no leading slash
+// ---------------------------------------------------------------------------
+
+test('ensureAppRoot returns the bare root unchanged when passed `/superset`', async () => {
+  const ensureAppRoot = await loadEnsureAppRoot('/superset');
+  expect(ensureAppRoot('/superset')).toBe('/superset');
+});
+
+test('ensureAppRoot does not double-prefix `/superset/` with trailing slash', async () => {
+  const ensureAppRoot = await loadEnsureAppRoot('/superset');
+  // `/superset/` starts with `/superset/` so the prefix already applies.
+  expect(ensureAppRoot('/superset/')).toBe('/superset/');
+});
+
+test('ensureAppRoot does not double-prefix a deeply nested already-rooted path', async () => {
+  const ensureAppRoot = await loadEnsureAppRoot('/superset');
+  expect(
+    ensureAppRoot('/superset/dashboard/list/?q=foo&page=2#section-3'),
+  ).toBe('/superset/dashboard/list/?q=foo&page=2#section-3');
+});
+
+// ---------------------------------------------------------------------------
+// Parity with SupersetClient.getUrl on the dedupe contract
+// ---------------------------------------------------------------------------
+//
+// Both helpers normalise the result so a pre-prefixed input does not produce
+// `/superset/superset/...`. They reach that result by different routes:
+//   - `ensureAppRoot` keeps the input when it already starts with the root.
+//   - `getUrl` strips the leading root segment before re-prefixing with
+//     `${host}${appRoot}/...`.
+//
+// These parametrised cases pin the same expected path-after-host for both
+// entry points across the inputs most likely to surface a regression.
+
+const PARITY_CASES: Array<{
+  description: string;
+  input: string;
+  expectedPath: string;
+}> = [
+  {
+    description: 'router-relative path without root prefix',
+    input: '/sqllab',
+    expectedPath: '/superset/sqllab',
+  },
+  {
+    description: 'router-relative path already carrying root prefix',
+    input: '/superset/sqllab',
+    expectedPath: '/superset/sqllab',
+  },
+  {
+    description: 'path with query string',
+    input: '/superset/api/v1/chart/?q=(filters:!t)',
+    expectedPath: '/superset/api/v1/chart/?q=(filters:!t)',
+  },
+  {
+    description: 'nested route under app root',
+    input: '/superset/dashboard/p/abc123/',
+    expectedPath: '/superset/dashboard/p/abc123/',
+  },
+  {
+    description: 'segment-boundary respect — /supersetfoo is NOT the root',
+    input: '/supersetfoo/welcome/',
+    expectedPath: '/superset/supersetfoo/welcome/',
+  },
+];
+
+test.each(PARITY_CASES)(
+  'ensureAppRoot and SupersetClient.getUrl agree on the post-root path: $description',
+  async ({ input, expectedPath }) => {
+    const ensureAppRoot = await loadEnsureAppRoot('/superset');
+    const client = clientFor('/superset');
+
+    const fromEnsureAppRoot = ensureAppRoot(input);
+    const fromGetUrl = client.getUrl({ endpoint: input });
+
+    expect(fromEnsureAppRoot).toBe(expectedPath);
+    // `getUrl` always emits absolute URLs; check the path-after-host equals
+    // the same expected post-root path.
+    expect(fromGetUrl).toBe(`https://example.com${expectedPath}`);
+  },
+);
+
+// ---------------------------------------------------------------------------
+// Root-deployment parity (appRoot = '')
+// ---------------------------------------------------------------------------
+
+test('ensureAppRoot is a no-op when root is empty', async () => {
+  const ensureAppRoot = await loadEnsureAppRoot('');
+  expect(ensureAppRoot('/sqllab')).toBe('/sqllab');
+  expect(ensureAppRoot('/dashboard/1/')).toBe('/dashboard/1/');
+});
+
+test('SupersetClient.getUrl matches ensureAppRoot under empty root', async () => {
+  const ensureAppRoot = await loadEnsureAppRoot('');
+  const client = clientFor('');
+  const input = '/sqllab?dbid=7';
+  expect(ensureAppRoot(input)).toBe('/sqllab?dbid=7');
+  expect(client.getUrl({ endpoint: input })).toBe(
+    'https://example.com/sqllab?dbid=7',
+  );
+});
diff --git a/superset-frontend/src/utils/pathUtils.test.ts b/superset-frontend/src/utils/pathUtils.test.ts
index 117e8eceab9..7018e2b23d3 100644
--- a/superset-frontend/src/utils/pathUtils.test.ts
+++ b/superset-frontend/src/utils/pathUtils.test.ts
@@ -246,6 +246,157 @@ test('makeUrl should be idempotent with subdirectory prefix', async () => {
   expect(twice).toBe(once); // /superset/sqllab?new=true, NOT /superset/superset/sqllab?new=true
 });
 
+test('stripAppRoot is a no-op when application root is empty', async () => {
+  document.body.innerHTML = '';
+  jest.resetModules();
+  const { stripAppRoot } = await import('./pathUtils');
+
+  expect(stripAppRoot('/welcome/')).toBe('/welcome/');
+  expect(stripAppRoot('/sqllab')).toBe('/sqllab');
+});
+
+test('stripAppRoot removes a leading application root from an already-rooted path', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('/superset/welcome/')).toBe('/welcome/');
+  expect(stripAppRoot('/superset/dashboard/list/')).toBe('/dashboard/list/');
+});
+
+test('stripAppRoot is idempotent — a path without the root is returned unchanged', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('/welcome/')).toBe('/welcome/');
+  expect(stripAppRoot(stripAppRoot('/superset/welcome/'))).toBe('/welcome/');
+});
+
+test('stripAppRoot returns "/" when given the bare application root', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('/superset')).toBe('/');
+});
+
+test('stripAppRoot returns "/" when given the application root with a trailing slash', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('/superset/')).toBe('/');
+});
+
+test('stripAppRoot respects segment boundaries — `/supersetfoo` is not the root', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('/supersetfoo/welcome/')).toBe('/supersetfoo/welcome/');
+});
+
+test('stripAppRoot handles nested application roots', async () => {
+  const { stripAppRoot } = await loadPathUtils('/preset/superset/');
+
+  expect(stripAppRoot('/preset/superset/welcome/')).toBe('/welcome/');
+  expect(stripAppRoot('/welcome/')).toBe('/welcome/');
+});
+
+test('stripAppRoot strips exactly one application root segment (single-pass)', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  // Single-pass strip preserves a
+  // legitimate `/superset/superset/<slug>` route. Under the single-prefix
+  // invariant the backend emits relative URLs and the frontend prefixes
+  // exactly once at the helper boundary, so a doubled leading segment is
+  // a real route, not a double-prefix bug. The normaliser at the
+  // SupersetClient inbound seam strips any inbound double prefix before
+  // it can reach this helper.
+  expect(stripAppRoot('/superset/superset/welcome/')).toBe(
+    '/superset/welcome/',
+  );
+  expect(stripAppRoot('/superset/superset/superset/welcome/')).toBe(
+    '/superset/superset/welcome/',
+  );
+});
+
+test('stripAppRoot passes absolute and protocol-relative URLs through', async () => {
+  const { stripAppRoot } = await loadPathUtils('/superset/');
+
+  expect(stripAppRoot('https://example.com/superset/welcome/')).toBe(
+    'https://example.com/superset/welcome/',
+  );
+  expect(stripAppRoot('//cdn.example.com/superset/x.png')).toBe(
+    '//cdn.example.com/superset/x.png',
+  );
+});
+
+// The protocol-relative
+// check in `ensureAppRoot` was `path.startsWith('//')` only — backslash
+// variants (`/\`, `\/`, `\\`) slipped past and were returned as router-
+// relative paths. Browsers later normalised `/\` → `//` in the special-
+// scheme authority, opening a cross-origin redirect for any consumer
+// that bypassed `assertSafeNavigationUrl`. The hardened check must
+// neutralise these inputs in lockstep with the navigation guard so the
+// composition pin (`assertSafeNavigationUrl(ensureAppRoot(input))`)
+// throws — proving the guard runs *after* `ensureAppRoot` and that
+// `ensureAppRoot` does not "launder" a backslash-relative target into a
+// guard-acceptable shape.
+
+test.each([
+  ['empty app root', ''],
+  ['/superset app root', '/superset/'],
+])(
+  'ensureAppRoot composition with assertSafeNavigationUrl rejects /\\evil.com under %s',
+  async (_label, appRoot) => {
+    const { ensureAppRoot } = await loadPathUtils(appRoot);
+    // `openInNewTab` exercises exactly `assertSafeNavigationUrl(ensureAppRoot(x))`
+    // — the composition we need to pin. Import it from the same module set so
+    // the bootstrap data + applicationRoot cache match.
+    const { openInNewTab } = await import('./navigationUtils');
+    const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+    try {
+      // ensureAppRoot must not silently pass `/\evil.com` through as if
+      // it were already-rooted; the composition with the guard must reject.
+      expect(() => openInNewTab('/\\evil.com')).toThrow(/refused unsafe URL/);
+      expect(openSpy).not.toHaveBeenCalled();
+      // Direct invariant: ensureAppRoot's return value must be rejected by
+      // the public-facing guard (asserted transitively via openInNewTab).
+      const ensured = ensureAppRoot('/\\evil.com');
+      expect(() => openInNewTab(ensured)).toThrow(/refused unsafe URL/);
+    } finally {
+      openSpy.mockRestore();
+    }
+  },
+);
+
+test('ensureAppRoot neutralises \\/evil.com via the composition guard', async () => {
+  const { ensureAppRoot: _ensureAppRoot } = await loadPathUtils('');
+  const { openInNewTab } = await import('./navigationUtils');
+  const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  try {
+    expect(() => openInNewTab('\\/evil.example.com')).toThrow(
+      /refused unsafe URL/,
+    );
+    expect(openSpy).not.toHaveBeenCalled();
+  } finally {
+    openSpy.mockRestore();
+  }
+});
+
+test('ensureAppRoot neutralises \\\\evil.com via the composition guard', async () => {
+  const { ensureAppRoot: _ensureAppRoot } = await loadPathUtils('');
+  const { openInNewTab } = await import('./navigationUtils');
+  const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
+  try {
+    expect(() => openInNewTab('\\\\evil.example.com')).toThrow(
+      /refused unsafe URL/,
+    );
+    expect(openSpy).not.toHaveBeenCalled();
+  } finally {
+    openSpy.mockRestore();
+  }
+});
+
+test('ensureAppRoot keeps protocol-relative //evil.com behaviour unchanged (regression)', async () => {
+  // `//evil.com` was already protocol-relative and already rejected by the
+  // navigation guard. The hardening must not change that.
+  const { ensureAppRoot } = await loadPathUtils('/superset/');
+  expect(ensureAppRoot('//evil.example.com')).toBe('//evil.example.com');
+});
+
 test('ensureAppRoot should fall back to application root when path is null or undefined', async () => {
   const { ensureAppRoot } = await loadPathUtils('/superset/');
 
diff --git a/superset-frontend/src/utils/pathUtils.ts b/superset-frontend/src/utils/pathUtils.ts
index b86faec2063..c2a1a483f14 100644
--- a/superset-frontend/src/utils/pathUtils.ts
+++ b/superset-frontend/src/utils/pathUtils.ts
@@ -35,19 +35,28 @@ const SAFE_ABSOLUTE_URL_RE = /^(https?|ftp|mailto|tel):/i;
  * Potentially dangerous schemes such as javascript: and data: are not treated
  * as absolute and will be prefixed.
  *
+ * Protocol-relative detection is backslash-aware: browsers normalise the
+ * leading `\` of `/\evil.com`, `\/evil.com`, `\\evil.com` etc. into `//` in
+ * the special-scheme authority, so these inputs are cross-origin navigations
+ * masquerading as router-relative paths. Returning them unchanged lets the
+ * downstream navigation guard reject them (see
+ * `navigationUtils.assertSafeNavigationUrl`).
+ *
  * If `path` is null or undefined, it falls back to the application root so the
  * caller (e.g. partial theme overrides leaving brand tokens unset) doesn't
  * crash.
  *
  * @param path A string path or URL to a resource
  */
+const PROTOCOL_RELATIVE_LIKE_RE = /^[/\\][/\\]/;
+
 export function ensureAppRoot(path: string | null | undefined): string {
-  if (path && (SAFE_ABSOLUTE_URL_RE.test(path) || path.startsWith('//'))) {
-    return path;
-  }
   if (path == null) {
     return applicationRoot() || '/';
   }
+  if (SAFE_ABSOLUTE_URL_RE.test(path) || PROTOCOL_RELATIVE_LIKE_RE.test(path)) {
+    return path;
+  }
   const root = applicationRoot();
   const normalizedPath = path.startsWith('/') ? path : `/${path}`;
   if (
@@ -76,3 +85,32 @@ export function ensureAppRoot(path: string | null | undefined): string {
 export function makeUrl(path: string): string {
   return ensureAppRoot(path);
 }
+
+/**
+ * Returns the path with a leading application-root segment removed. Useful when
+ * handing an already-rooted path to a consumer that re-prepends the root —
+ * e.g. react-router-dom's Link resolves its `to` prop against the Router's
+ * `basename`, so passing an already-rooted path would result in a doubled
+ * prefix in the rendered anchor href.
+ *
+ * Idempotent: stripping a path that does not start with the application root
+ * returns it unchanged. Absolute URLs and protocol-relative URLs pass through.
+ *
+ * @param path - The path to strip
+ * @returns The path without a leading application-root segment
+ */
+export function stripAppRoot(path: string): string {
+  if (SAFE_ABSOLUTE_URL_RE.test(path) || path.startsWith('//')) {
+    return path;
+  }
+  const root = applicationRoot();
+  if (!root) return path;
+  // Single-pass strip mirrors
+  // `normalizeBackendUrlString` and `SupersetClientClass.getUrl` exactly. A
+  // genuine `/superset/superset/<slug>` is a legitimate route under the
+  // single-prefix invariant (backend emits relative URLs, frontend prefixes once),
+  // not a double-prefix bug. Greedy stripping would corrupt such routes.
+  if (path === root) return '/';
+  if (path.startsWith(`${root}/`)) return path.slice(root.length);
+  return path;
+}
diff --git a/superset-frontend/src/utils/urlUtils.test.ts b/superset-frontend/src/utils/urlUtils.test.ts
index 9a7cd79cda8..a3a5d468ed4 100644
--- a/superset-frontend/src/utils/urlUtils.test.ts
+++ b/superset-frontend/src/utils/urlUtils.test.ts
@@ -20,6 +20,7 @@
 import {
   isUrlExternal,
   parseUrl,
+  rewritePermalinkOrigin,
   toQueryString,
   getDashboardUrlParams,
   getUrlParam,
@@ -165,3 +166,133 @@ test('getUrlParam uses provided search string instead of window.location.search
 
   locationSpy.mockRestore();
 });
+
+const originalLocationForRewriteTests = window.location;
+
+const setBrowsingOriginForRewriteTests = (origin: string) => {
+  Object.defineProperty(window, 'location', {
+    value: { ...originalLocationForRewriteTests, origin },
+    writable: true,
+    configurable: true,
+  });
+};
+
+const restoreLocationForRewriteTests = () => {
+  Object.defineProperty(window, 'location', {
+    value: originalLocationForRewriteTests,
+    writable: true,
+    configurable: true,
+  });
+};
+
+test('rewritePermalinkOrigin replaces backend host with browsing origin', () => {
+  // Reproduces the docker-light share-embed bug: the backend returns the
+  // internal container hostname; the iframe must use the browsing origin
+  // instead.
+  setBrowsingOriginForRewriteTests('http://localhost:9004');
+  expect(
+    rewritePermalinkOrigin(
+      'http://superset-light:8088/superset/explore/p/abc/',
+    ),
+  ).toBe('http://localhost:9004/superset/explore/p/abc/');
+  restoreLocationForRewriteTests();
+});
+
+test('rewritePermalinkOrigin preserves query string and hash', () => {
+  setBrowsingOriginForRewriteTests('http://localhost:9004');
+  expect(
+    rewritePermalinkOrigin(
+      'http://superset-light:8088/superset/explore/p/abc/?standalone=1&height=400#x',
+    ),
+  ).toBe(
+    'http://localhost:9004/superset/explore/p/abc/?standalone=1&height=400#x',
+  );
+  restoreLocationForRewriteTests();
+});
+
+test('rewritePermalinkOrigin is a no-op when origins already match', () => {
+  setBrowsingOriginForRewriteTests('https://superset.example.com');
+  expect(
+    rewritePermalinkOrigin(
+      'https://superset.example.com/superset/dashboard/p/xyz/',
+    ),
+  ).toBe('https://superset.example.com/superset/dashboard/p/xyz/');
+  restoreLocationForRewriteTests();
+});
+
+test('rewritePermalinkOrigin returns input unchanged when URL cannot be parsed', () => {
+  setBrowsingOriginForRewriteTests('http://localhost:9004');
+  expect(rewritePermalinkOrigin('not a url')).toBe('not a url');
+  expect(rewritePermalinkOrigin('/relative/path')).toBe('/relative/path');
+  restoreLocationForRewriteTests();
+});
+
+test('rewritePermalinkOrigin returns input unchanged when window.location.origin is missing', () => {
+  // Some test setups stub window.location to a minimal shape with no
+  // origin (e.g., ShareMenuItems tests). The rewrite must no-op rather
+  // than producing "undefined/..." URLs.
+  Object.defineProperty(window, 'location', {
+    value: { href: '' },
+    writable: true,
+    configurable: true,
+  });
+  expect(
+    rewritePermalinkOrigin('http://superset.com/superset/dashboard/p/x/'),
+  ).toBe('http://superset.com/superset/dashboard/p/x/');
+  restoreLocationForRewriteTests();
+});
+
+// M5 opt-OUT: when the Flask config `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE`
+// is True the frontend must hand back the backend-supplied URL untouched —
+// proxied/subdir deployments stay on the default (rewrite enabled), but
+// operators whose reverse proxy correctly forwards `X-Forwarded-Host` AND who
+// want backend literal origins can opt out without touching the call sites.
+// The `cachedBootstrapData` lives at module scope, so the fixture has to
+// jest.resetModules() AFTER installing the `#app` data-bootstrap shape and
+// dynamic-import `urlUtils` to pick up the new value.
+async function withRewriteFlag<T>(
+  flagValue: boolean,
+  body: (
+    rewriteFn: (typeof import('./urlUtils'))['rewritePermalinkOrigin'],
+  ) => Promise<T> | T,
+): Promise<T> {
+  const previousBody = document.body.innerHTML;
+  const bootstrapData = {
+    common: {
+      application_root: '',
+      static_assets_prefix: '',
+      conf: { EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE: flagValue },
+    },
+  };
+  document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(
+    bootstrapData,
+  )}'></div>`;
+  jest.resetModules();
+  try {
+    const fresh = await import('./urlUtils');
+    return await body(fresh.rewritePermalinkOrigin);
+  } finally {
+    document.body.innerHTML = previousBody;
+    jest.resetModules();
+  }
+}
+
+test('rewritePermalinkOrigin is a no-op when EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE is true', async () => {
+  setBrowsingOriginForRewriteTests('http://localhost:9004');
+  await withRewriteFlag(true, rewrite => {
+    expect(rewrite('http://superset-light:8088/superset/explore/p/abc/')).toBe(
+      'http://superset-light:8088/superset/explore/p/abc/',
+    );
+  });
+  restoreLocationForRewriteTests();
+});
+
+test('rewritePermalinkOrigin still rewrites when EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE is false (default)', async () => {
+  setBrowsingOriginForRewriteTests('http://localhost:9004');
+  await withRewriteFlag(false, rewrite => {
+    expect(rewrite('http://superset-light:8088/superset/explore/p/abc/')).toBe(
+      'http://localhost:9004/superset/explore/p/abc/',
+    );
+  });
+  restoreLocationForRewriteTests();
+});
diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts
index b7889442d3d..f38611ebafd 100644
--- a/superset-frontend/src/utils/urlUtils.ts
+++ b/superset-frontend/src/utils/urlUtils.ts
@@ -172,6 +172,40 @@ function getPermalink(
   }));
 }
 
+/**
+ * Replaces the origin of an absolute URL with `window.location.origin`,
+ * preserving path, query, and hash. Returns the input unchanged if it does
+ * not parse as an absolute URL.
+ *
+ * Why: the backend builds permalinks with `url_for(_external=True)`, which
+ * trusts the request `Host` header. When `ENABLE_PROXY_FIX` is off or the
+ * proxy doesn't forward `X-Forwarded-Host` (docker-light, K8s services
+ * without ingress rewriting, etc.), the URL carries an internal hostname
+ * unreachable from the user's browser.
+ *
+ * Operator opt-OUT: the Flask config
+ * `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default False) returns the
+ * backend-supplied URL untouched. Operators whose reverse proxy correctly
+ * forwards `X-Forwarded-Host` AND who want permalinks to carry the backend's
+ * literal origin can set the flag to True.
+ */
+export function rewritePermalinkOrigin(url: string): string {
+  const conf = getBootstrapData().common?.conf ?? {};
+  if (conf.EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE === true) {
+    return url;
+  }
+  const browsingOrigin = window.location.origin;
+  if (!browsingOrigin) {
+    return url;
+  }
+  try {
+    const parsed = new URL(url);
+    return `${browsingOrigin}${parsed.pathname}${parsed.search}${parsed.hash}`;
+  } catch {
+    return url;
+  }
+}
+
 /**
  * Resolves a permalink URL using the host app's custom callback if in embedded mode.
  * Falls back to the default URL if not embedded or if no callback is provided.
@@ -200,9 +234,14 @@ async function resolvePermalinkUrl(
       // Silently fall back to default URL if Switchboard call fails
       // (e.g., if not in embedded context or callback throws)
     }
+    // Embedded host opted out (no callback, or threw): the backend URL is
+    // the only signal we have. Skip the origin rewrite — Superset is in
+    // an iframe and the iframe's origin is not necessarily reachable from
+    // where the user will paste the embed.
+    return { key, url };
   }
 
-  return { key, url };
+  return { key, url: rewritePermalinkOrigin(url) };
 }
 
 export async function getChartPermalink(
diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx
index 62472faa7b7..6e254e5f4e5 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -104,7 +104,7 @@ const RouteSwitch = () => {
           </Suspense>
         </Route>
       ))}
-      <Redirect from="/" to="/superset/welcome/" exact />
+      <Redirect from="/" to="/welcome/" exact />
     </Switch>
   );
 };
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index fa69b584070..f250676d4bb 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -42,7 +42,7 @@ import type {
 } from 'src/components';
 import Chart, { Slice } from 'src/types/Chart';
 import copyTextToClipboard from 'src/utils/copy';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+import { getShareableUrl } from 'src/utils/navigationUtils';
 import SupersetText from 'src/utils/textUtils';
 import { DatabaseObject } from 'src/features/databases/types';
 import {
@@ -747,9 +747,7 @@ export const copyQueryLink = (
   addSuccessToast: (arg0: string) => void,
 ) => {
   copyTextToClipboard(() =>
-    Promise.resolve(
-      `${window.location.origin}${ensureAppRoot(`/sqllab?savedQueryId=${id}`)}`,
-    ),
+    Promise.resolve(getShareableUrl(`/sqllab?savedQueryId=${id}`)),
   )
     .then(() => {
       addSuccessToast(t('Link Copied!'));
diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts
index 7cc682ea632..0295e0897e4 100644
--- a/superset-frontend/src/views/routePaths.ts
+++ b/superset-frontend/src/views/routePaths.ts
@@ -23,9 +23,9 @@ export const RoutePaths = {
   REGISTER_ACTIVATION: '/register/activation/:activationHash',
   REGISTER: '/register/',
   LOGOUT: '/logout/',
-  HOME: '/superset/welcome/',
-  FILE_HANDLER: '/superset/file-handler',
-  DASHBOARD: '/superset/dashboard/:idOrSlug/',
+  HOME: '/welcome/',
+  FILE_HANDLER: '/file-handler',
+  DASHBOARD: '/dashboard/:idOrSlug/',
   DASHBOARD_LIST: '/dashboard/list/',
   CHART_ADD: '/chart/add',
   CHART_LIST: '/chart/list/',
@@ -42,7 +42,7 @@ export const RoutePaths = {
   ALERT_LOG: '/alert/:alertId/log/',
   REPORT_LOG: '/report/:alertId/log/',
   EXPLORE: '/explore/',
-  EXPLORE_PERMALINK: '/superset/explore/p',
+  EXPLORE_PERMALINK: '/explore/p',
   DATASET_ADD: '/dataset/add/',
   DATASET: '/dataset/:datasetId',
   ROW_LEVEL_SECURITY: '/rowlevelsecurity/list',
@@ -51,8 +51,8 @@ export const RoutePaths = {
   USER_INFO: '/user_info/',
   ACTION_LOG: '/actionlog/list',
   REGISTRATIONS: '/registrations/',
-  ALL_ENTITIES: '/superset/all_entities/',
-  TAGS: '/superset/tags/',
+  ALL_ENTITIES: '/all_entities/',
+  TAGS: '/tags/',
   ROLES: '/roles/',
   USERS: '/users/',
   GROUPS: '/list_groups/',
diff --git a/superset-frontend/src/views/routes.test.tsx b/superset-frontend/src/views/routes.test.tsx
index 9f0e945ac0c..8fc4f516b04 100644
--- a/superset-frontend/src/views/routes.test.tsx
+++ b/superset-frontend/src/views/routes.test.tsx
@@ -31,4 +31,53 @@ describe('isFrontendRoute', () => {
   test('returns false if a route does not match', () => {
     expect(isFrontendRoute('/nonexistent/path/')).toBe(false);
   });
+
+  test('does not pattern-match parameterised routes', () => {
+    // Documented limitation: lookup is a literal dictionary check, so a
+    // concrete `/dashboard/123/` does not match the registered
+    // `/dashboard/:idOrSlug/` key. Callers must accept the full-page-reload
+    // fallback for these URLs.
+    expect(isFrontendRoute('/dashboard/123/')).toBe(false);
+  });
+});
+
+test('routes used to mount the Superset.* SPA pages no longer carry a /superset prefix', () => {
+  // After `Superset.route_base = ""`, the SPA paths must mirror the
+  // post-basename pathname seen by React Router. The legacy shapes still
+  // appearing here would mean Home or Dashboard renders blank under
+  // subdirectory deployment.
+  const paths = routes.map(r => r.path);
+  expect(paths).toContain('/welcome/');
+  expect(paths).toContain('/dashboard/:idOrSlug/');
+  expect(paths).not.toContain('/superset/welcome/');
+  expect(paths).not.toContain('/superset/dashboard/:idOrSlug/');
+});
+
+test('isFrontendRoute accepts both bare-route and appRoot-prefixed menu URLs', async () => {
+  // Menu URLs flow from bootstrap_data via `url_for(...)` and already carry
+  // the active appRoot (e.g. `/superset/welcome/`). The check must succeed
+  // against both the bare route shape and the app-root-prefixed shape so
+  // Menu.tsx routes them through React Router instead of forcing a reload.
+  const previousBody = document.body.innerHTML;
+  try {
+    const bootstrapData = {
+      common: {
+        application_root: '/superset',
+        conf: { AUTH_USER_REGISTRATION: false },
+      },
+    };
+    document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
+    jest.resetModules();
+    const { isFrontendRoute: scopedIsFrontendRoute } = await import('./routes');
+    // Bare route shape (post-basename) — what React Router sees.
+    expect(scopedIsFrontendRoute('/welcome/')).toBe(true);
+    expect(scopedIsFrontendRoute('/dashboard/list/')).toBe(true);
+    // App-root-prefixed shape — what bootstrap_data emits via url_for(...).
+    expect(scopedIsFrontendRoute('/superset/welcome/')).toBe(true);
+    expect(scopedIsFrontendRoute('/superset/dashboard/list/')).toBe(true);
+    expect(scopedIsFrontendRoute('/superset/welcome/?edit=true')).toBe(true);
+  } finally {
+    document.body.innerHTML = previousBody;
+    jest.resetModules();
+  }
 });
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 4c18e28ab42..0e1901256fb 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -26,6 +26,7 @@ import {
 } from 'react';
 import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
 import getBootstrapData from 'src/utils/getBootstrapData';
+import { stripAppRoot } from 'src/utils/navigationUtils';
 import { RoutePaths } from './routePaths';
 
 // not lazy loaded since this is the home page.
@@ -277,7 +278,16 @@ const frontEndRoutes: Record<string, boolean> = routes
 
 export const isFrontendRoute = (path?: string): boolean => {
   if (path) {
-    const basePath = path.split(/[?#]/)[0]; // strip out query params and link bookmarks
+    // Strip query / hash, then strip the application-root segment so menu URLs
+    // emitted by the backend (`url_for(...)` → `/<appRoot>/<route>`) match
+    // against the route table, which is keyed by post-basename paths.
+    //
+    // Note: this is a literal dictionary lookup, not a path-pattern match —
+    // parameterised routes such as `/dashboard/:idOrSlug/` are NOT matched
+    // by a concrete `/dashboard/123/` URL. Callers relying on that behaviour
+    // (e.g., the brand-link SPA-route check in `Menu.tsx`) accept a
+    // full-page-reload fallback for those URLs.
+    const basePath = stripAppRoot(path.split(/[?#]/)[0]);
     return !!frontEndRoutes[basePath];
   }
   return false;
diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js
index 2c150acaa99..9fff26604ad 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -152,11 +152,7 @@ const plugins = [
   }),
 
   new CopyPlugin({
-    patterns: [
-      'package.json',
-      { from: 'src/assets/images', to: 'images' },
-      { from: 'src/pwa-manifest.json', to: 'pwa-manifest.json' },
-    ],
+    patterns: ['package.json', { from: 'src/assets/images', to: 'images' }],
   }),
 
   // static pages
diff --git a/superset/app.py b/superset/app.py
index 391d633488d..3b90d74b3d7 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -42,6 +42,7 @@ from superset.extensions.local_extensions_watcher import (
 )
 from superset.initialization import SupersetAppInitializer
 from superset.marshmallow_compatibility import patch_marshmallow_for_flask_appbuilder
+from superset.middleware.legacy_prefix_redirect import LegacyPrefixRedirectMiddleware
 
 patch_marshmallow_for_flask_appbuilder()
 
@@ -69,6 +70,12 @@ def create_app(
             or os.environ.get("SUPERSET_APP_ROOT")
             or app.config["APPLICATION_ROOT"],
         )
+        # Normalize once at the source: a trailing slash ("/myapp/") would
+        # otherwise leak into STATIC_ASSETS_PREFIX ("/myapp//static/...")
+        # and APPLICATION_ROOT (the session-cookie path). The middlewares
+        # below re-normalize defensively, but every consumer derives from
+        # this value.
+        app_root = app_root.rstrip("/") or "/"
         if app_root != "/":
             # If not set, manually configure options that depend on the
             # value of app_root so things work out of the box
@@ -87,6 +94,21 @@ def create_app(
         if app_root != "/":
             app.wsgi_app = AppRootMiddleware(app.wsgi_app, app_root)
 
+        # Final WSGI wrap — must be outermost so it sees the raw inbound
+        # PATH_INFO and 308s legacy `/superset/*` paths before any other
+        # routing runs. Unconditional (independent of `app_root != "/"`)
+        # because legacy bookmarks exist under root deployments too.
+        # See the "Layering invariant" section of the module docstring
+        # in `superset/middleware/legacy_prefix_redirect.py`.
+        # mypy reads `app.wsgi_app` as `object` after the conditional
+        # `AppRootMiddleware` rewrap above — the LUB of the two branches
+        # widens it. The runtime type is always a WSGI callable; the
+        # `# type: ignore` is intentional.
+        app.wsgi_app = LegacyPrefixRedirectMiddleware(
+            app.wsgi_app,  # type: ignore[arg-type]
+            app_root,
+        )
+
         # Set up LOCAL_EXTENSIONS file watcher when in debug mode
         if app.debug:
             start_local_extensions_watcher_thread(app)
@@ -205,15 +227,20 @@ class AppRootMiddleware:
         app_root: str,
     ):
         self.wsgi_app = wsgi_app
-        self.app_root = app_root
+        # Normalize a trailing slash so "/myapp" and "/myapp/" configure the
+        # same prefix and the segment-boundary check below stays uniform.
+        self.app_root = app_root.rstrip("/")
 
     def __call__(
         self, environ: WSGIEnvironment, start_response: StartResponse
     ) -> Iterable[bytes]:
         original_path_info = environ.get("PATH_INFO", "")
-        if original_path_info.startswith(self.app_root):
-            environ["PATH_INFO"] = original_path_info.removeprefix(self.app_root)
+        # Segment-boundary match: accept "/myapp" and "/myapp/..." but never
+        # a path that merely shares a string prefix (e.g. "/myapparoo/...").
+        if original_path_info == self.app_root or original_path_info.startswith(
+            self.app_root + "/"
+        ):
+            environ["PATH_INFO"] = original_path_info[len(self.app_root) :]
             environ["SCRIPT_NAME"] = self.app_root
             return self.wsgi_app(environ, start_response)
-        else:
-            return NotFound()(environ, start_response)
+        return NotFound()(environ, start_response)
diff --git a/superset/config.py b/superset/config.py
index 0f918224fcc..2dde3fbee8c 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2552,6 +2552,16 @@ PREVENT_UNSAFE_DB_CONNECTIONS = True
 # If true all default urls on datasets will be handled as relative URLs by the frontend
 PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = True
 
+# Opt-OUT for the frontend permalink-origin rewrite. By default the frontend
+# substitutes `window.location.origin` for the backend-supplied origin on
+# share/permalink URLs so a proxied or subdirectory-deployed Superset does not
+# hand the user an unreachable internal hostname. Operators whose reverse proxy
+# correctly forwards `X-Forwarded-Host` AND who *want* permalinks to carry the
+# backend's literal origin can set this flag to True to disable the rewrite.
+# Default False (rewrite is on) — flipping the default to True would regress
+# the dominant proxied/subdir deployment to an unreachable internal host.
+EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = False
+
 # Define a list of allowed URL patterns (regex) for dataset data imports (v1).
 # Simple example to only allow URLs that belong to certain domains:
 # DATASET_IMPORT_ALLOWED_DATA_URLS = [
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 89174704bd8..a43e90de55a 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -25,6 +25,7 @@ from collections.abc import Hashable
 from dataclasses import dataclass, field
 from datetime import timedelta
 from typing import Any, Callable, cast, Optional, Union
+from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
 
 import pandas as pd
 import sqlalchemy as sa
@@ -1513,7 +1514,26 @@ class SqlaTable(
 
     @property
     def sql_url(self) -> str:
-        return self.database.sql_url + "?table_name=" + str(self.table_name)
+        # `Database.sql_url` returns `/sqllab/?dbid=N` — the base
+        # already carries a query string, so the historical string concat
+        # `+ "?table_name=" + str(...)` produced a second literal `?` and
+        # corrupted the query parser. Parse + append + re-encode so
+        # `table_name` is a real query param. `urlencode(quote_via=quote)`
+        # delegates per-value escaping to `quote(safe="")`, which encodes
+        # `/`, `&`, `=`, `#`, space, etc. — load-bearing for table names
+        # containing those characters (regression tested).
+        parts = urlsplit(self.database.sql_url)
+        query = parse_qsl(parts.query, keep_blank_values=True)
+        query.append(("table_name", str(self.table_name)))
+        return urlunsplit(
+            (
+                parts.scheme,
+                parts.netloc,
+                parts.path,
+                urlencode(query, quote_via=quote),
+                parts.fragment,
+            )
+        )
 
     def external_metadata(self) -> list[ResultSetColumnType]:
         # todo(yongjie): create a physical table column type in a separate PR
diff --git a/superset/extensions/__init__.py b/superset/extensions/__init__.py
index e704a2a4048..840e0447009 100644
--- a/superset/extensions/__init__.py
+++ b/superset/extensions/__init__.py
@@ -108,7 +108,16 @@ class UIManifestProcessor:
             "js_manifest": lambda bundle: get_files(bundle, "js"),
             "css_manifest": lambda bundle: get_files(bundle, "css"),
             "assets_prefix": (  # type: ignore
-                self.app.config["STATIC_ASSETS_PREFIX"] if self.app else ""
+                self.app.config.get("STATIC_ASSETS_PREFIX", "") if self.app else ""
+            ),
+            # rstripped APPLICATION_ROOT for templates that build app-rooted
+            # URLs (e.g. spa.html's `<link rel="manifest">`). `/` → ``,
+            # `/superset` → `/superset`, `/superset/` → `/superset` so
+            # `f"{application_root_rstrip}/path"` is single-prefixed.
+            "application_root_rstrip": (  # type: ignore
+                (self.app.config.get("APPLICATION_ROOT") or "").rstrip("/")
+                if self.app
+                else ""
             ),
         }
 
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 07eb274a02f..c57e85086cc 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -214,6 +214,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         from superset.views.groups import GroupsListView
         from superset.views.log.api import LogRestApi
         from superset.views.logs import ActionLogView
+        from superset.views.pwa_manifest import PwaManifestView
         from superset.views.redirect import RedirectView
         from superset.views.roles import RolesListView
         from superset.views.sql_lab.views import (
@@ -305,10 +306,15 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         if app_root.endswith("/"):
             app_root = app_root.rstrip("/")
 
+        # FAB renders this href verbatim in menus and does not apply
+        # SCRIPT_NAME at render time, so the application_root has to be baked
+        # in at registration. The previous hardcoded `/superset/welcome/`
+        # collided with non-`/superset/` deployments AND with the new
+        # `Superset.route_base = ""`. `app_root` is normalized above.
         appbuilder.add_link(
             "Home",
             label=_("Home"),
-            href="/superset/welcome/",
+            href=f"{app_root}/welcome/",
             cond=lambda: bool(current_app.config["LOGO_TARGET_PATH"]),
         )
 
@@ -462,6 +468,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_view_no_menu(TaggedObjectsModelView)
         appbuilder.add_view_no_menu(TagView)
         appbuilder.add_view_no_menu(ReportView)
+        appbuilder.add_view_no_menu(PwaManifestView)
         appbuilder.add_view_no_menu(RedirectView)
         appbuilder.add_view_no_menu(RoleRestAPI)
         appbuilder.add_view_no_menu(UserInfoView)
diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py
index d579bba42a5..91df24e8f91 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -1601,7 +1601,7 @@ def _safe_user_label(value: Any) -> str | None:
 def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
     include_data_model_metadata = user_can_view_data_model_metadata()
     base_url = get_superset_base_url()
-    relative_url = dashboard.url  # e.g. "/superset/dashboard/{slug_or_id}/"
+    relative_url = dashboard.url  # e.g. "/dashboard/{slug_or_id}/"
     absolute_url = f"{base_url}{relative_url}" if relative_url else None
     json_metadata_str = getattr(dashboard, "json_metadata", None)
     position_json_str = getattr(dashboard, "position_json", None)
@@ -1669,9 +1669,7 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
     slug = getattr(dashboard, "slug", None)
     dashboard_url = None
     if dashboard_id is not None:
-        dashboard_url = (
-            f"{get_superset_base_url()}/superset/dashboard/{slug or dashboard_id}/"
-        )
+        dashboard_url = f"{get_superset_base_url()}/dashboard/{slug or dashboard_id}/"
 
     json_metadata_str = getattr(dashboard, "json_metadata", None)
     position_json_str = getattr(dashboard, "position_json", None)
diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index 4f64d53cbd0..7569b2bc343 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -578,7 +578,7 @@ def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural
                     exc_info=True,
                 )
             dashboard_url = (
-                f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/"
+                f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/"
             )
             position_info = {
                 "row": row_key,
@@ -613,7 +613,7 @@ def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural
             created_on=updated_dashboard.created_on,
             changed_on=updated_dashboard.changed_on,
             uuid=str(updated_dashboard.uuid) if updated_dashboard.uuid else None,
-            url=f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/",
+            url=f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/",
             chart_count=len(updated_dashboard.slices),
             tags=[
                 serialize_tag_object(tag)
@@ -633,9 +633,7 @@ def add_chart_to_existing_dashboard(  # noqa: C901 — complexity is structural
             ],
         )
 
-        dashboard_url = (
-            f"{get_superset_base_url()}/superset/dashboard/{updated_dashboard.id}/"
-        )
+        dashboard_url = f"{get_superset_base_url()}/dashboard/{updated_dashboard.id}/"
 
         logger.info(
             "Added chart %s to dashboard %s ", request.chart_id, request.dashboard_id
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 029b777c822..4830224569e 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -425,8 +425,7 @@ def generate_dashboard(  # noqa: C901
         # response.dashboard_url) share a single source. Prefer the slug
         # over the id to match ``update_dashboard``'s canonical URL shape.
         dashboard_url = (
-            f"{get_superset_base_url()}/superset/dashboard/"
-            f"{dashboard.slug or dashboard.id}/"
+            f"{get_superset_base_url()}/dashboard/{dashboard.slug or dashboard.id}/"
         )
 
         # Re-fetch with eager-loaded relationships for serialization.
diff --git a/superset/mcp_service/dashboard/tool/update_dashboard.py b/superset/mcp_service/dashboard/tool/update_dashboard.py
index fa0782de45d..4027a9c5872 100644
--- a/superset/mcp_service/dashboard/tool/update_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/update_dashboard.py
@@ -47,10 +47,7 @@ logger = logging.getLogger(__name__)
 
 def _build_dashboard_url(dashboard: Any) -> str:
     """Build the user-facing dashboard URL, preferring slug over id."""
-    return (
-        f"{get_superset_base_url()}/superset/dashboard/"
-        f"{dashboard.slug or dashboard.id}/"
-    )
+    return f"{get_superset_base_url()}/dashboard/{dashboard.slug or dashboard.id}/"
 
 
 def _find_and_authorize_dashboard(
diff --git a/superset/middleware/__init__.py b/superset/middleware/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/middleware/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/middleware/legacy_prefix_redirect.py b/superset/middleware/legacy_prefix_redirect.py
new file mode 100644
index 00000000000..61747ba57fa
--- /dev/null
+++ b/superset/middleware/legacy_prefix_redirect.py
@@ -0,0 +1,256 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Outermost WSGI shim that 308-redirects legacy ``/superset/<canonical>``
+paths to ``/<canonical>``.
+
+After ``Superset.route_base = ""`` collapsed the historical ``/superset``
+prefix off every view, bookmarks/email links/external integrations to the
+legacy paths would 404. This middleware closes that gap for one release
+cycle (removed at EOL ``5.0.0``, matching the ``@deprecated`` gate on
+``Superset.explore`` and ``Superset.explore_json``).
+
+Layering invariant
+------------------
+This shim must wrap **outside** every other WSGI middleware so it sees the
+raw inbound ``PATH_INFO``. ``superset/app.py`` installs it unconditionally
+*after* ``init_app()`` returns (and therefore after
+``configure_middlewares()`` has applied ``AppRootMiddleware`` /
+``ProxyFix`` / ``ChunkedEncodingFix`` / ``ADDITIONAL_MIDDLEWARE``).
+
+The ``Location`` header is built from the ``app_root`` captured at
+construction + the canonical path. It **never** consults
+``environ["SCRIPT_NAME"]`` (set only at request time by
+``AppRootMiddleware``, which wraps inside this shim) or any
+``X-Forwarded-*`` header. Proxies that strip the legacy prefix externally
+are out of scope — the proxy must forward the raw client path for the
+shim to see it.
+
+Disposition rules
+-----------------
+* GET against any enumerated row → 308 Permanent Redirect.
+* POST against a POST-capable canonical → 308 (body-preserving).
+* POST against a GET-only canonical → 410 Gone (308 would 405 on retry).
+* Anything not in :data:`LEGACY_REDIRECT_MAP` → pass through unchanged.
+
+``/superset/sql/<database_id>/`` is intentionally **not** redirected:
+``Database.sql_url`` changed shape to ``/sqllab/?dbid=<id>`` (query
+string, not a path) so no 1:1 mapping exists. ``UPDATING.md`` documents
+the hard re-bookmark break.
+"""
+
+from __future__ import annotations
+
+import sys
+from typing import Iterable, Optional
+from urllib.parse import quote
+
+from werkzeug.wrappers import Response
+
+if sys.version_info >= (3, 11):
+    from wsgiref.types import StartResponse, WSGIApplication, WSGIEnvironment
+else:
+    from typing import TYPE_CHECKING
+
+    if TYPE_CHECKING:
+        from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
+
+#: The legacy URL token the shim recognises. Hard-coded — not configurable.
+_LEGACY_PREFIX: str = "/superset"
+
+#: Closed table mapping legacy canonical paths (the part *after*
+#: ``_LEGACY_PREFIX``) to ``(allowed_methods, canonical_path)``.
+#:
+#: Keys ending in ``/`` are **prefix** rules — they match any inbound path
+#: that starts with the key, and the tail is appended to the canonical
+#: path verbatim. Keys without a trailing ``/`` are **exact** rules.
+#:
+#: Each method tuple is verified against the canonical endpoint's actual
+#: ``@expose`` decorator at HEAD ``1bc20f2206``; any change to a canonical
+#: endpoint's allowed methods MUST update the corresponding row (and the
+#: closed-set regression test in
+#: ``tests/integration_tests/middleware/test_legacy_prefix_redirect.py``).
+LEGACY_REDIRECT_MAP: dict[str, tuple[frozenset[str], str]] = {
+    # views/core.py — Superset (route_base = "")
+    "/welcome/": (frozenset({"GET"}), "/welcome/"),
+    "/dashboard/": (frozenset({"GET"}), "/dashboard/"),
+    "/dashboard/p/": (frozenset({"GET"}), "/dashboard/p/"),
+    "/slice/": (frozenset({"GET"}), "/slice/"),
+    "/warm_up_cache/": (frozenset({"GET"}), "/warm_up_cache/"),
+    "/fetch_datasource_metadata": (
+        frozenset({"GET"}),
+        "/fetch_datasource_metadata",
+    ),
+    "/language_pack/": (frozenset({"GET"}), "/language_pack/"),
+    "/file-handler": (frozenset({"GET"}), "/file-handler"),
+    "/log/": (frozenset({"POST"}), "/log/"),
+    "/sqllab/history/": (frozenset({"GET"}), "/sqllab/history/"),
+    "/explore_json/": (frozenset({"GET", "POST"}), "/explore_json/"),
+    "/explore_json/data/": (frozenset({"GET"}), "/explore_json/data/"),
+    # views/explore.py — ExploreView (route_base = "/explore") owns the
+    # bare /explore/ rule; Superset.explore (deprecated) provides the
+    # POST handler. ExplorePermalinkView.permalink (route_base = "")
+    # owns /explore/p/<key>/.
+    "/explore/": (frozenset({"GET", "POST"}), "/explore/"),
+    "/explore/p/": (frozenset({"GET"}), "/explore/p/"),
+    # views/tags.py — TagModelView.list (route_base = "/tags", @expose("/"))
+    "/tags/": (frozenset({"GET"}), "/tags/"),
+    # views/all_entities.py — TaggedObjectsModelView.list (route_base = "/all_entities")
+    "/all_entities/": (frozenset({"GET"}), "/all_entities/"),
+}
+
+# Precompute prefix keys ordered longest-first so /dashboard/p/ wins over
+# /dashboard/ etc. Static — computed at module import, no per-request cost.
+_PREFIX_KEYS: tuple[str, ...] = tuple(
+    sorted(
+        (k for k in LEGACY_REDIRECT_MAP if k.endswith("/")),
+        key=len,
+        reverse=True,
+    )
+)
+
+
+def _match(
+    canonical_after_strip: str,
+) -> Optional[tuple[frozenset[str], str]]:
+    """Find the matching row for a path that has had ``_LEGACY_PREFIX``
+    already stripped.
+
+    Returns ``(allowed_methods, canonical_target)`` or ``None`` if the
+    path is not in the closed set.
+    """
+    # 1. Exact match on a non-prefix key (e.g. "/file-handler").
+    if canonical_after_strip in LEGACY_REDIRECT_MAP:
+        methods, target = LEGACY_REDIRECT_MAP[canonical_after_strip]
+        if not canonical_after_strip.endswith("/"):
+            return methods, target
+        # Trailing-slash keys (e.g. "/dashboard/") deliberately fall through to
+        # step 2 instead of early-returning. A trailing-slash key is also a
+        # prefix key, so the same key handles both the bare path ("/dashboard/",
+        # tail == "") and any sub-path ("/dashboard/5/", tail == "5/") through a
+        # single code path. Early-returning here would special-case the bare
+        # path for no gain — step 2 yields the identical (methods, target) row.
+    # 2. Longest-prefix match across keys ending in "/".
+    for key in _PREFIX_KEYS:
+        if canonical_after_strip.startswith(key):
+            methods, target_root = LEGACY_REDIRECT_MAP[key]
+            tail = canonical_after_strip[len(key) :]
+            return methods, target_root + tail
+    return None
+
+
+class LegacyPrefixRedirectMiddleware:
+    """308-redirects legacy ``/superset/<canonical>`` paths to ``/<canonical>``.
+
+    Mirrors the construction-time capture pattern used by
+    :class:`superset.app.AppRootMiddleware`. ``app_root`` is captured here
+    and never re-read from ``environ`` — see the module docstring for why.
+    """
+
+    def __init__(
+        self,
+        wsgi_app: "WSGIApplication",
+        app_root: str,
+    ):
+        self.wsgi_app = wsgi_app
+        # Strip a single trailing slash so "/" and "/superset/" both
+        # produce the same Location prefix at build time. Empty string is
+        # correct for app_root == "/".
+        self.app_root_prefix: str = app_root.rstrip("/")
+        # If the operator deploys under
+        # APPLICATION_ROOT == "/superset", the legacy prefix collides with
+        # the app-root prefix and `app_root_prefix + canonical_target`
+        # produces the same URL as the inbound path → infinite 308 loop.
+        # In that deployment shape there is no migration to perform anyway
+        # (legacy URLs are already at their canonical location), so the
+        # shim becomes a no-op.
+        self._enabled: bool = self.app_root_prefix != _LEGACY_PREFIX
+
+    def __call__(
+        self,
+        environ: "WSGIEnvironment",
+        start_response: "StartResponse",
+    ) -> Iterable[bytes]:
+        # Short-circuit when APPLICATION_ROOT == _LEGACY_PREFIX
+        # (legacy and canonical prefixes coincide → 308 self-loop). See
+        # `__init__` for the full rationale.
+        if not self._enabled:
+            return self.wsgi_app(environ, start_response)
+
+        path_info: str = environ.get("PATH_INFO", "")
+        method: str = environ.get("REQUEST_METHOD", "GET").upper()
+
+        # Under a subdirectory deployment, legacy bookmarks carry the app
+        # root too (`/myapp/superset/dashboard/1/`) — this shim sits outside
+        # AppRootMiddleware, so the raw PATH_INFO still has the app-root
+        # prefix. Strip it (segment-boundary aware) before the legacy-prefix
+        # check so both `/superset/...` and `{app_root}/superset/...` forms
+        # are recognised. The Location below is built from the captured
+        # app_root either way, so both forms 308 to the same canonical URL.
+        candidate = path_info
+        if self.app_root_prefix and candidate.startswith(self.app_root_prefix + "/"):
+            candidate = candidate[len(self.app_root_prefix) :]
+
+        # Cheap exit: not a /superset path at all.
+        if not (
+            candidate == _LEGACY_PREFIX or candidate.startswith(_LEGACY_PREFIX + "/")
+        ):
+            return self.wsgi_app(environ, start_response)
+
+        canonical_after_strip = candidate[len(_LEGACY_PREFIX) :] or "/"
+        match = _match(canonical_after_strip)
+        if match is None:
+            # Unenumerated /superset path — closed-set discipline: pass
+            # through to inner app (typically 404 from AppRootMiddleware /
+            # Flask routing). The shim is a closed-set redirector, not an
+            # open-prefix rewriter.
+            return self.wsgi_app(environ, start_response)
+
+        allowed_methods, canonical_target = match
+        if method not in allowed_methods:
+            # POST against GET-only canonical (308 would have the client
+            # retry-POST against the canonical → 405). Emit 410 explicitly
+            # so the operator-facing signal is unambiguous.
+            return _response_with_location(410, None)(environ, start_response)
+
+        # Location is intentionally built from captured app_root + canonical
+        # path. Never from environ["SCRIPT_NAME"] (AppRootMiddleware wraps
+        # inside this shim and sets SCRIPT_NAME at request time, but it is
+        # unset at this outer layer) and never from X-Forwarded-*. See
+        # module docstring for the proxy-strips-prefix operator caveat.
+        location = self.app_root_prefix + canonical_target
+        if query_string := environ.get("QUERY_STRING", ""):
+            location = f"{location}?{query_string}"
+
+        return _response_with_location(308, location)(environ, start_response)
+
+
+def _response_with_location(status: int, location: Optional[str]) -> Response:
+    """Build an empty WSGI ``Response`` with the given status + optional
+    ``Location`` header.
+
+    ``Location`` is added only for 308 (308 requires it; 410 does not).
+    The body is empty in both cases — clients follow 308 by re-requesting
+    the new URL and surface 410 to the user as a hard break.
+    """
+    headers: list[tuple[str, str]] = []
+    if location is not None:
+        # quote() with safe="/?&=#%:" keeps already-encoded query bytes
+        # intact while ensuring any raw control bytes that snuck in from
+        # PATH_INFO are header-safe. Most realistic inputs round-trip
+        # untouched (PATH_INFO is already %-encoded by the server).
+        headers.append(("Location", quote(location, safe="/?&=#%:+,;@!$'()*-_.~")))
+    return Response(status=status, headers=headers)
diff --git a/superset/models/core.py b/superset/models/core.py
index 5a8c6e6e173..cdd47006af9 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -1296,7 +1296,11 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin):  # pylint:
 
     @property
     def sql_url(self) -> str:
-        return f"/superset/sql/{self.id}/"
+        # SQL Lab moved to its own blueprint at /sqllab/; the legacy
+        # /superset/sql/<id>/ route was removed when Superset.route_base
+        # collapsed to "". Deep-link by databaseId instead so this property
+        # resolves to a live route under any application_root.
+        return f"/sqllab/?dbid={self.id}"
 
     @hybrid_property
     def perm(self) -> str:
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index 4653272fcbf..3f9e4afa739 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -22,7 +22,7 @@ from collections import defaultdict, deque
 from typing import Any, Callable
 
 import sqlalchemy as sqla
-from flask import current_app as app
+from flask import current_app as app, has_request_context, url_for
 from flask_appbuilder import Model
 from flask_appbuilder.models.decorators import renders
 from flask_appbuilder.security.sqla.models import User
@@ -197,12 +197,12 @@ class Dashboard(CoreDashboard, AuditMixinNullable, ImportExportMixin):
 
     @property
     def url(self) -> str:
-        return f"/superset/dashboard/{self.slug or self.id}/"
+        return f"/dashboard/{self.slug or self.id}/"
 
     @staticmethod
     def get_url(id_: int, slug: str | None = None) -> str:
         # To be able to generate URL's without instantiating a Dashboard object
-        return f"/superset/dashboard/{slug or id_}/"
+        return f"/dashboard/{slug or id_}/"
 
     @property
     def datasources(self) -> set[BaseDatasource]:
@@ -221,10 +221,16 @@ class Dashboard(CoreDashboard, AuditMixinNullable, ImportExportMixin):
     @renders("dashboard_title")
     def dashboard_link(self) -> Markup:
         title = escape(self.dashboard_title or "<empty>")
-        # self.url embeds the user-controlled slug; escape it before it is
-        # marked safe via Markup (mirrors SqlaTable.link).
-        url = escape(self.url)
-        return Markup(f'<a href="{url}">{title}</a>')
+        # FAB list view renders this raw HTML; use url_for so Flask prepends
+        # SCRIPT_NAME (the application_root) and the row link works under
+        # subdirectory deployments. `Dashboard.url` itself stays router-
+        # relative so frontend callers can apply ensureAppRoot exactly once.
+        # url_for percent-encodes the user-controlled slug path param; escape
+        # the result before Markup-marking for HTML attribute defence-in-depth.
+        href = escape(
+            url_for("Superset.dashboard", dashboard_id_or_slug=self.slug or self.id)
+        )
+        return Markup(f'<a href="{href}">{title}</a>')
 
     @property
     def digest(self) -> str | None:
@@ -237,7 +243,14 @@ class Dashboard(CoreDashboard, AuditMixinNullable, ImportExportMixin):
         if the dashboard has changed
         """
         if digest := self.digest:
-            return f"/api/v1/dashboard/{self.id}/thumbnail/{digest}/"
+            if not has_request_context():
+                # Out-of-request callers (CLI, celery tasks) have no
+                # SCRIPT_NAME to honor; keep the router-relative shape so
+                # the property stays callable anywhere.
+                return f"/api/v1/dashboard/{self.id}/thumbnail/{digest}/"
+            # url_for respects SCRIPT_NAME, so the URL carries the application
+            # root prefix under subdirectory deployments.
+            return url_for("DashboardRestApi.thumbnail", pk=self.id, digest=digest)
 
         return None
 
diff --git a/superset/models/slice.py b/superset/models/slice.py
index 40de049df2a..889d6302bab 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -21,7 +21,7 @@ from typing import Any, TYPE_CHECKING
 from urllib import parse
 
 import sqlalchemy as sqla
-from flask import current_app
+from flask import current_app, has_request_context, url_for
 from flask_appbuilder import Model
 from flask_appbuilder.models.decorators import renders
... 4494 lines suppressed ...