diff --git a/src/core/plugins/deep-linking/helpers.js b/src/core/plugins/deep-linking/helpers.js
index d06bbe27dc1..ccfef08d8d0 100644
--- a/src/core/plugins/deep-linking/helpers.js
+++ b/src/core/plugins/deep-linking/helpers.js
@@ -1,6 +1,11 @@
export const setHash = (value) => {
if(value) {
- return history.pushState(null, null, `#${value}`)
+ // Include the current pathname and search explicitly so that a
+ // document-level `` (set by frameworks like Angular) does not
+ // cause `pushState` to resolve `#value` against the base URL and replace
+ // the current route. See https://github.com/swagger-api/swagger-ui/issues/10591
+ const { pathname, search } = window.location
+ return history.pushState(null, null, `${pathname}${search}#${value}`)
} else {
return window.location.hash = ""
}
diff --git a/test/e2e-cypress/e2e/bugs/10591.cy.js b/test/e2e-cypress/e2e/bugs/10591.cy.js
new file mode 100644
index 00000000000..3b946027315
--- /dev/null
+++ b/test/e2e-cypress/e2e/bugs/10591.cy.js
@@ -0,0 +1,33 @@
+describe("#10591: deep-linking with document should not replace current route", () => {
+ const baseUrl = "/?deepLinking=true&url=/documents/features/deep-linking.openapi.yaml"
+
+ it("should preserve the page pathname and search when an operation is expanded", () => {
+ cy.intercept(
+ {
+ method: "GET",
+ pathname: "/",
+ },
+ (req) => {
+ req.continue((res) => {
+ if (typeof res.body === "string" && res.body.includes("")) {
+ res.body = res.body.replace("", "\n")
+ }
+ })
+ }
+ ).as("indexHtml")
+
+ cy.visit(baseUrl)
+ cy.wait("@indexHtml")
+ cy.get(".opblock-get").first().click()
+
+ cy.location().should((loc) => {
+ // The URL hash must change to reflect the deep link...
+ expect(loc.hash).to.not.equal("")
+ // ...but the pathname (and search) must NOT have been replaced by the
+ // browser resolving "#..." against the document .
+ expect(loc.pathname).to.equal("/")
+ expect(loc.search).to.contain("deepLinking=true")
+ expect(loc.search).to.contain("url=/documents/features/deep-linking.openapi.yaml")
+ })
+ })
+})
diff --git a/test/unit/core/plugins/deep-linking/helpers.js b/test/unit/core/plugins/deep-linking/helpers.js
new file mode 100644
index 00000000000..ea866b66612
--- /dev/null
+++ b/test/unit/core/plugins/deep-linking/helpers.js
@@ -0,0 +1,63 @@
+/**
+ * @prettier
+ */
+import { setHash } from "core/plugins/deep-linking/helpers"
+
+describe("deep-linking plugin - helpers", () => {
+ describe("setHash", () => {
+ let originalLocation
+ let pushStateSpy
+
+ beforeEach(() => {
+ originalLocation = window.location
+ pushStateSpy = jest
+ .spyOn(window.history, "pushState")
+ .mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ pushStateSpy.mockRestore()
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: originalLocation,
+ })
+ })
+
+ const stubLocation = (pathname, search) => {
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { ...originalLocation, pathname, search, hash: "" },
+ })
+ }
+
+ it("should preserve the current pathname and search when pushing a hash", () => {
+ stubLocation("/app/api-docs", "?env=staging")
+
+ setHash("/myTag/myOperation")
+
+ expect(pushStateSpy).toHaveBeenCalledWith(
+ null,
+ null,
+ "/app/api-docs?env=staging#/myTag/myOperation"
+ )
+ })
+
+ it("should still push a hash when the current URL has no search string", () => {
+ stubLocation("/swagger", "")
+
+ setHash("/foo")
+
+ expect(pushStateSpy).toHaveBeenCalledWith(null, null, "/swagger#/foo")
+ })
+
+ it("should clear window.location.hash when called with an empty value", () => {
+ stubLocation("/swagger", "")
+ window.location.hash = "#/foo"
+
+ setHash("")
+
+ expect(window.location.hash).toBe("")
+ expect(pushStateSpy).not.toHaveBeenCalled()
+ })
+ })
+})