diff --git a/e2e/tests/urlParams.spec.ts b/e2e/tests/urlParams.spec.ts
new file mode 100644
index 000000000..bdb9e8f7e
--- /dev/null
+++ b/e2e/tests/urlParams.spec.ts
@@ -0,0 +1,351 @@
+import { expect, test } from "@playwright/test";
+import { clientUrl, registerNewUser, registerDandiset } from "../utils.ts";
+import { faker } from "@faker-js/faker";
+
+/**
+ * Tests that listing query parameters (page, sortOption, sortDir, showDrafts,
+ * showEmpty, search) do not leak into Dandiset Landing Page (DLP) URLs,
+ * and vice-versa. Note: ?pos= IS allowed on DLP URLs (position in result set).
+ * See https://github.com/dandi/dandi-archive/issues/1460
+ */
+
+// These listing-specific params must NOT appear on DLP URLs.
+// Note: 'pos' and 'search' are allowed — pos for result set position, search for context.
+const LISTING_PARAMS = ["page", "sortOption", "sortDir", "showDrafts", "showEmpty"];
+
+/** Assert that none of the listing-specific params appear in the current URL. */
+function expectNoLeakedParams(url: string) {
+ const parsed = new URL(url);
+ for (const param of LISTING_PARAMS) {
+ expect(parsed.searchParams.has(param), `URL should not contain '${param}': ${url}`).toBeFalsy();
+ }
+}
+
+test.describe("URL parameter isolation (issue #1460)", () => {
+ test("listing params do not leak to DLP when clicking a dandiset", async ({ page }) => {
+ await registerNewUser(page);
+ const name = faker.lorem.words();
+ const description = faker.lorem.sentences();
+ await registerDandiset(page, name, description);
+
+ // Go to the public dandisets listing
+ await page.goto(`${clientUrl}/dandiset`);
+ await page.waitForLoadState("networkidle");
+
+ // Click the first dandiset in the list
+ await page.locator(".v-list-item").first().click();
+ await page.waitForLoadState("networkidle");
+
+ // DLP URL must not contain listing params
+ expectNoLeakedParams(page.url());
+ // Should be on a dandiset page with pos param
+ expect(page.url()).toMatch(/\/dandiset\/\d+/);
+ const url = new URL(page.url());
+ expect(url.searchParams.has("pos")).toBeTruthy();
+ });
+
+ test("search context is preserved but listing params do not leak to DLP", async ({ page }) => {
+ await registerNewUser(page);
+ const name = `searchtest-${faker.lorem.word()}`;
+ const description = faker.lorem.sentences();
+ await registerDandiset(page, name, description);
+
+ // Navigate to search with a query
+ await page.goto(`${clientUrl}/dandiset/search?search=${name}`);
+ await page.waitForLoadState("networkidle");
+
+ // Verify search results appear
+ const items = page.locator(".v-list-item");
+ await expect(items.first()).toBeVisible();
+
+ // Click the first result
+ await items.first().click();
+ await page.waitForLoadState("networkidle");
+
+ // DLP URL must not have listing params
+ expectNoLeakedParams(page.url());
+ // But search and pos should be present for context
+ const url = new URL(page.url());
+ expect(url.searchParams.get("search")).toBe(name);
+ expect(url.searchParams.has("pos")).toBeTruthy();
+ });
+
+ test("direct DLP navigation produces clean URL", async ({ page }) => {
+ await registerNewUser(page);
+ const name = faker.lorem.words();
+ const description = faker.lorem.sentences();
+ const dandisetId = await registerDandiset(page, name, description);
+
+ // Navigate directly to the DLP
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
+ await page.waitForLoadState("networkidle");
+
+ expectNoLeakedParams(page.url());
+ });
+
+ test("DLP pagination updates pos but does not add listing params", async ({ page }) => {
+ test.slow();
+ await registerNewUser(page);
+
+ // Create multiple dandisets so pagination has something to page through
+ for (let i = 0; i < 3; i += 1) {
+ await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+ }
+
+ // Go to listing and click a dandiset
+ await page.goto(`${clientUrl}/dandiset`);
+ await page.waitForLoadState("networkidle");
+ await page.locator(".v-list-item").first().click();
+ await page.waitForLoadState("networkidle");
+
+ const initialPos = new URL(page.url()).searchParams.get("pos");
+ expect(initialPos).toBeTruthy();
+
+ // If pagination is visible, click next and verify pos changes but no listing params leak
+ const nextButton = page.locator(".v-pagination__next button");
+ if (await nextButton.isVisible()) {
+ await nextButton.click();
+ await page.waitForLoadState("networkidle");
+ expectNoLeakedParams(page.url());
+ const newPos = new URL(page.url()).searchParams.get("pos");
+ expect(newPos).toBeTruthy();
+ expect(newPos).not.toBe(initialPos);
+ }
+ });
+
+ test("back button from DLP restores listing params", async ({ page }) => {
+ await registerNewUser(page);
+ await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ // Go to listing
+ await page.goto(`${clientUrl}/dandiset`);
+ await page.waitForLoadState("networkidle");
+
+ // Capture listing URL
+ const listingUrl = page.url();
+
+ // Click into a dandiset
+ await page.locator(".v-list-item").first().click();
+ await page.waitForLoadState("networkidle");
+
+ // Go back
+ await page.goBack();
+ await page.waitForLoadState("networkidle");
+
+ // Should be back on listing with its own params intact
+ const backUrl = new URL(page.url());
+ expect(backUrl.pathname).toBe("/dandiset");
+ });
+
+ test("manually appended listing params are stripped from DLP URL", async ({ page }) => {
+ await registerNewUser(page);
+ const name = faker.lorem.words();
+ const description = faker.lorem.sentences();
+ const dandisetId = await registerDandiset(page, name, description);
+
+ // Navigate to DLP with manually injected listing params
+ // pos and search are allowed; page/sortOption/sortDir/showDrafts/showEmpty are not
+ await page.goto(
+ `${clientUrl}/dandiset/${dandisetId}?page=2&sortOption=1&sortDir=-1&showDrafts=true&showEmpty=false&search=test&pos=5`
+ );
+ await page.waitForLoadState("networkidle");
+
+ // The router guard should have stripped listing params but kept pos and search
+ expectNoLeakedParams(page.url());
+ const url = new URL(page.url());
+ expect(url.searchParams.get("pos")).toBe("5");
+ expect(url.searchParams.get("search")).toBe("test");
+ });
+});
+
+test.describe("DLP tab URL state", () => {
+ test("clicking 'How to Cite' tab adds ?tab=how-to-cite to URL", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
+ await page.waitForLoadState("networkidle");
+
+ // URL should have no tab param on Overview (default)
+ expect(new URL(page.url()).searchParams.has("tab")).toBeFalsy();
+
+ // Click "How to Cite" tab
+ await page.getByRole("tab", { name: "How to Cite" }).click();
+ await page.waitForTimeout(500);
+
+ // URL should now have ?tab=how-to-cite
+ const url = new URL(page.url());
+ expect(url.searchParams.get("tab")).toBe("how-to-cite");
+ });
+
+ test("switching back to Overview removes tab param", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
+ await page.waitForLoadState("networkidle");
+
+ // Should be on How to Cite tab
+ await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
+
+ // Switch to Overview
+ await page.getByRole("tab", { name: "Overview" }).click();
+ await page.waitForTimeout(500);
+
+ // tab param should be gone
+ expect(new URL(page.url()).searchParams.has("tab")).toBeFalsy();
+ });
+
+ test("navigating to ?tab=how-to-cite opens the correct tab", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ // Navigate directly with tab param
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
+ await page.waitForLoadState("networkidle");
+
+ // The How to Cite content should be visible
+ await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
+ });
+
+ test("tab param survives page reload", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}`);
+ await page.waitForLoadState("networkidle");
+
+ // Click How to Cite
+ await page.getByRole("tab", { name: "How to Cite" }).click();
+ await page.waitForTimeout(500);
+
+ // Reload
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Should still be on How to Cite
+ await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
+ expect(new URL(page.url()).searchParams.get("tab")).toBe("how-to-cite");
+ });
+});
+
+test.describe("DLP citation format URL state", () => {
+ test("selecting a citation format adds ?format= to URL", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
+ await page.waitForLoadState("networkidle");
+
+ // Default (APA) should have no format param
+ expect(new URL(page.url()).searchParams.has("format")).toBeFalsy();
+
+ // Select BibTeX format
+ await page.locator(".citation-format-select").click();
+ await page.getByRole("option", { name: "BibTeX" }).click();
+ await page.waitForTimeout(500);
+
+ const url = new URL(page.url());
+ expect(url.searchParams.get("format")).toBe("bibtex");
+ expect(url.searchParams.get("tab")).toBe("how-to-cite");
+ });
+
+ test("navigating to ?tab=how-to-cite&format=harvard opens correct format", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite&format=harvard`);
+ await page.waitForLoadState("networkidle");
+
+ // Harvard should be selected in the dropdown
+ await expect(page.locator(".citation-format-select")).toContainText("Harvard");
+ });
+
+ test("citation format survives page reload", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite`);
+ await page.waitForLoadState("networkidle");
+
+ // Select Harvard
+ await page.locator(".citation-format-select").click();
+ await page.getByRole("option", { name: "Harvard" }).click();
+ await page.waitForTimeout(500);
+
+ // Reload
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Should still be on How to Cite with Harvard selected
+ await expect(page.getByText("How to Cite this Dataset")).toBeVisible();
+ await expect(page.locator(".citation-format-select")).toContainText("Harvard");
+ const url = new URL(page.url());
+ expect(url.searchParams.get("tab")).toBe("how-to-cite");
+ expect(url.searchParams.get("format")).toBe("harvard");
+ });
+
+ test("switching back to APA removes format param", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?tab=how-to-cite&format=bibtex`);
+ await page.waitForLoadState("networkidle");
+
+ // Select APA (default)
+ await page.locator(".citation-format-select").click();
+ await page.getByRole("option", { name: "APA 7th" }).click();
+ await page.waitForTimeout(500);
+
+ // format param should be gone, tab should remain
+ const url = new URL(page.url());
+ expect(url.searchParams.has("format")).toBeFalsy();
+ expect(url.searchParams.get("tab")).toBe("how-to-cite");
+ });
+});
+
+test.describe("DLP meditor overlay URL state", () => {
+ test("opening meditor adds ?overlay=meditor to URL", async ({ page }) => {
+ await registerNewUser(page);
+ await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+ await page.waitForLoadState("networkidle");
+
+ // No overlay param initially
+ expect(new URL(page.url()).searchParams.has("overlay")).toBeFalsy();
+
+ // Click the metadata edit button to open meditor
+ await page.getByText("Metadata", { exact: true }).click();
+ await page.waitForTimeout(500);
+
+ expect(new URL(page.url()).searchParams.get("overlay")).toBe("meditor");
+ });
+
+ test("closing meditor removes overlay param", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?overlay=meditor`);
+ await page.waitForLoadState("networkidle");
+
+ // Meditor dialog should be open
+ await expect(page.locator(".v-overlay--active .v-card")).toBeVisible();
+
+ // Close the dialog (press Escape)
+ await page.keyboard.press("Escape");
+ await page.waitForTimeout(500);
+
+ // overlay param should be gone
+ expect(new URL(page.url()).searchParams.has("overlay")).toBeFalsy();
+ });
+
+ test("navigating to ?overlay=meditor opens the meditor", async ({ page }) => {
+ await registerNewUser(page);
+ const dandisetId = await registerDandiset(page, faker.lorem.words(), faker.lorem.sentences());
+
+ await page.goto(`${clientUrl}/dandiset/${dandisetId}?overlay=meditor`);
+ await page.waitForLoadState("networkidle");
+
+ // Meditor dialog should be visible
+ await expect(page.locator(".v-overlay--active .v-card")).toBeVisible();
+ });
+});
diff --git a/web/src/components/DLP/HowToCiteTab.vue b/web/src/components/DLP/HowToCiteTab.vue
index 0a480aaf8..8e4e27bc0 100644
--- a/web/src/components/DLP/HowToCiteTab.vue
+++ b/web/src/components/DLP/HowToCiteTab.vue
@@ -231,7 +231,8 @@
diff --git a/web/src/views/DandisetLandingView/DandisetMain.vue b/web/src/views/DandisetLandingView/DandisetMain.vue
index e179b0ff2..6da07b773 100644
--- a/web/src/views/DandisetLandingView/DandisetMain.vue
+++ b/web/src/views/DandisetLandingView/DandisetMain.vue
@@ -267,6 +267,7 @@
import {
computed,
ref,
+ watch,
watchEffect,
type ComputedRef,
} from 'vue';
@@ -276,6 +277,7 @@ import { marked } from 'marked';
import moment from 'moment';
import DOMPurify from 'dompurify';
import { useDisplay } from 'vuetify';
+import { useRoute, useRouter } from 'vue-router';
import { useDandisetStore } from '@/stores/dandiset';
import { getDoiMetadata } from '@/utils/doi';
@@ -292,11 +294,13 @@ const MAX_DESCRIPTION_LENGTH = 400;
const tabs = [
{
name: 'Overview',
+ slug: 'overview',
component: OverviewTab,
icon: 'mdi-information-outline',
},
{
name: 'How to Cite',
+ slug: 'how-to-cite',
component: HowToCiteTab,
icon: 'mdi-format-quote-close',
},
@@ -358,7 +362,27 @@ const subjectMatter: ComputedRef = computed
() => meta.value?.about,
);
-const currentTab = ref(0);
+const route = useRoute();
+const router = useRouter();
+
+// Resolve the initial tab from the ?tab= query param (default to 0 / Overview).
+const initialTabIndex = tabs.findIndex((t) => t.slug === route.query.tab);
+const currentTab = ref(initialTabIndex >= 0 ? initialTabIndex : 0);
+
+// Sync tab selection → URL query param.
+watch(currentTab, (index) => {
+ const slug = tabs[index]?.slug;
+ const currentSlug = route.query.tab as string | undefined;
+ if (slug === currentSlug) return;
+ // Use the default tab (overview) without a query param to keep the base URL clean.
+ const query = { ...route.query };
+ if (index === 0) {
+ delete query.tab;
+ } else {
+ query.tab = slug;
+ }
+ router.replace({ ...route, query });
+});
function formatDate(date: string): string {
return moment(date).format('LL');