From 1abae325ac8aa7f17f0ff8f500d36cf6800d5428 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 27 Oct 2026 16:04:35 +0100 Subject: [PATCH 1/2] test: pin website listing hash, pagination and update behavior --- .../listings/interactions/.gitignore | 3 + .../listings/interactions/_quarto.yml | 7 + .../listings/interactions/index.qmd | 22 +++ .../listings/interactions/posts/post-1.qmd | 7 + .../listings/interactions/posts/post-2.qmd | 7 + .../listings/interactions/posts/post-3.qmd | 7 + .../listings/interactions/posts/thumb.png | Bin 0 -> 70 bytes .../tests/listing-interactions.spec.ts | 130 ++++++++++++++++++ 8 files changed, 183 insertions(+) create mode 100644 tests/docs/playwright/listings/interactions/.gitignore create mode 100644 tests/docs/playwright/listings/interactions/_quarto.yml create mode 100644 tests/docs/playwright/listings/interactions/index.qmd create mode 100644 tests/docs/playwright/listings/interactions/posts/post-1.qmd create mode 100644 tests/docs/playwright/listings/interactions/posts/post-2.qmd create mode 100644 tests/docs/playwright/listings/interactions/posts/post-3.qmd create mode 100644 tests/docs/playwright/listings/interactions/posts/thumb.png create mode 100644 tests/integration/playwright/tests/listing-interactions.spec.ts diff --git a/tests/docs/playwright/listings/interactions/.gitignore b/tests/docs/playwright/listings/interactions/.gitignore new file mode 100644 index 0000000000..ab1fa232cc --- /dev/null +++ b/tests/docs/playwright/listings/interactions/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +**/*.quarto_ipynb +/_site/ diff --git a/tests/docs/playwright/listings/interactions/_quarto.yml b/tests/docs/playwright/listings/interactions/_quarto.yml new file mode 100644 index 0000000000..7645c9cdca --- /dev/null +++ b/tests/docs/playwright/listings/interactions/_quarto.yml @@ -0,0 +1,7 @@ +project: + type: website + +website: + title: "Listing update handlers" + +format: html diff --git a/tests/docs/playwright/listings/interactions/index.qmd b/tests/docs/playwright/listings/interactions/index.qmd new file mode 100644 index 0000000000..6d95d224ad --- /dev/null +++ b/tests/docs/playwright/listings/interactions/index.qmd @@ -0,0 +1,22 @@ +--- +title: "Two paginated listings" +listing: + - id: first-listing + contents: posts + type: default + sort: "title asc" + page-size: 1 + categories: true + filter-ui: true + - id: second-listing + contents: posts + type: default + sort: "title asc" + page-size: 1 +--- + +::: {#first-listing} +::: + +::: {#second-listing} +::: diff --git a/tests/docs/playwright/listings/interactions/posts/post-1.qmd b/tests/docs/playwright/listings/interactions/posts/post-1.qmd new file mode 100644 index 0000000000..c486bc8ec2 --- /dev/null +++ b/tests/docs/playwright/listings/interactions/posts/post-1.qmd @@ -0,0 +1,7 @@ +--- +title: "Post 1" +categories: [cat1] +image: thumb.png +--- + +Post 1 content. diff --git a/tests/docs/playwright/listings/interactions/posts/post-2.qmd b/tests/docs/playwright/listings/interactions/posts/post-2.qmd new file mode 100644 index 0000000000..207c88cedf --- /dev/null +++ b/tests/docs/playwright/listings/interactions/posts/post-2.qmd @@ -0,0 +1,7 @@ +--- +title: "Post 2" +categories: [cat2] +image: thumb.png +--- + +Post 2 content. diff --git a/tests/docs/playwright/listings/interactions/posts/post-3.qmd b/tests/docs/playwright/listings/interactions/posts/post-3.qmd new file mode 100644 index 0000000000..6763662aee --- /dev/null +++ b/tests/docs/playwright/listings/interactions/posts/post-3.qmd @@ -0,0 +1,7 @@ +--- +title: "Post 3" +categories: [cat3] +image: thumb.png +--- + +Post 3 content. diff --git a/tests/docs/playwright/listings/interactions/posts/thumb.png b/tests/docs/playwright/listings/interactions/posts/thumb.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/tests/integration/playwright/tests/listing-interactions.spec.ts b/tests/integration/playwright/tests/listing-interactions.spec.ts new file mode 100644 index 0000000000..1d303e2e37 --- /dev/null +++ b/tests/integration/playwright/tests/listing-interactions.spec.ts @@ -0,0 +1,130 @@ +import { expect, Page, test } from "@playwright/test"; + +const fixture = "./listings/interactions/_site/index.html"; +const listingIds = ["first-listing", "second-listing"]; + +const titles = (page: Page, listingId: string) => + page.locator(`#listing-${listingId} .list .listing-title`); + +const pageLink = (page: Page, listingId: string, n: string) => + page.locator(`#${listingId}-pagination .page.page-link`, { hasText: n }); + +const categoryEl = (page: Page, name: string) => + page.locator(".quarto-listing-category .category", { hasText: name }); + +async function changeHash(page: Page, hash: string) { + await page.evaluate((hash) => + new Promise((resolve) => { + window.addEventListener("hashchange", () => resolve(), { once: true }); + window.location.hash = hash; + }), hash); +} + +test.describe("Listing categories", () => { + test("category title click resets the filter", async ({ page }) => { + await page.goto(fixture); + await categoryEl(page, "cat3").click(); + await expect(titles(page, "first-listing")).toHaveText(["Post 3"]); + + await page.locator(".quarto-listing-category-title").click(); + + await expect(titles(page, "first-listing")).toHaveText(["Post 1"]); + await expect(page.locator("#first-listing-pagination .page.page-link")).toHaveCount(3); + await expect(page.locator(".quarto-listing-category .category.active")) + .toHaveAttribute("data-category", ""); + }); + + test("hash change to a category filters the listings", async ({ page }) => { + await page.goto(fixture); + + await changeHash(page, "category=cat3"); + + for (const listingId of listingIds) { + await expect(titles(page, listingId)).toHaveText(["Post 3"]); + } + }); + + test("back and forward restore the category", async ({ page }) => { + await page.goto(fixture); + await categoryEl(page, "cat3").click(); + await expect(titles(page, "first-listing")).toHaveText(["Post 3"]); + await categoryEl(page, "cat1").click(); + await expect(titles(page, "first-listing")).toHaveText(["Post 1"]); + + await page.goBack(); + await expect(titles(page, "first-listing")).toHaveText(["Post 3"]); + await expect(categoryEl(page, "cat3")).toHaveClass(/active/); + + await page.goForward(); + await expect(titles(page, "first-listing")).toHaveText(["Post 1"]); + await expect(categoryEl(page, "cat1")).toHaveClass(/active/); + }); +}); + +test.describe("Listing pagination", () => { + test("page hash on initial load shows that page of that listing only", async ({ page }) => { + await page.goto(`${fixture}#listing-second-listing-page=2`); + + await expect(titles(page, "second-listing")).toHaveText(["Post 2"]); + await expect(titles(page, "first-listing")).toHaveText(["Post 1"]); + }); + + test("page links rebuilt after an update still write the page hash", async ({ page }) => { + await page.goto(fixture); + await pageLink(page, "second-listing", "2").click(); + await expect(titles(page, "second-listing")).toHaveText(["Post 2"]); + + await pageLink(page, "second-listing", "3").click(); + + await expect(titles(page, "second-listing")).toHaveText(["Post 3"]); + await expect(page).toHaveURL(/#listing-second-listing-page=3$/); + }); + + test("back restores the previous page", async ({ page }) => { + await page.goto(fixture); + await pageLink(page, "second-listing", "2").click(); + await expect(titles(page, "second-listing")).toHaveText(["Post 2"]); + await pageLink(page, "second-listing", "3").click(); + await expect(titles(page, "second-listing")).toHaveText(["Post 3"]); + + await page.goBack(); + + await expect(titles(page, "second-listing")).toHaveText(["Post 2"]); + }); +}); + +test.describe("Listing update side effects", () => { + test("progressive image gets its src once its page is shown", async ({ page }) => { + await page.goto(fixture); + // list.js keeps hidden items out of the DOM; read Post 2 through its item. + const hiddenImageAttrs = await page.evaluate(() => { + const list = (window as any)["quarto-listings"]["listing-second-listing"]; + const img = list.items[1].elm.querySelector("img.thumbnail-image"); + return { src: img.getAttribute("src"), dataSrc: img.getAttribute("data-src") }; + }); + expect(hiddenImageAttrs.src).toBeNull(); + expect(hiddenImageAttrs.dataSrc).toMatch(/thumb\.png$/); + + const image = page.locator("#listing-second-listing .list img.thumbnail-image"); + await pageLink(page, "second-listing", "2").click(); + + await expect(titles(page, "second-listing")).toHaveText(["Post 2"]); + await expect(image).toHaveAttribute("src", /thumb\.png$/); + await expect(image).not.toHaveAttribute("data-src"); + }); + + test("no matching message follows the filter", async ({ page }) => { + await page.goto(fixture); + const noMatching = page.locator("#listing-first-listing .listing-no-matching"); + const filter = page.locator("#listing-first-listing").getByPlaceholder("Filter"); + await expect(noMatching).toHaveClass(/d-none/); + + // list.js searches on keyup, which fill() alone does not send. + await filter.fill("nothing matches this"); + await filter.press("Enter"); + await expect(noMatching).not.toHaveClass(/d-none/); + + await filter.fill(""); + await expect(noMatching).toHaveClass(/d-none/); + }); +}); From cb3074697f3923956c2690fbb3f8fa44015008fc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 27 Oct 2026 16:11:14 +0100 Subject: [PATCH 2/2] Register each listing's update handler once quarto-listing-loaded both applied the URL hash and attached list.on("updated") to every listing registered so far. Each listing's inline script called it on DOMContentLoaded and again from its own window hashchange listener, so the first of two listings started with two handlers and every hash change added one handler per listing on the page to each listing. The hashchange listener was added in 962d83dbe for #1139 and only needs the hash part; back/forward between categories relies on it too. Wiring is now guarded per listing id, and a single hashchange listener in quarto-listing.js only applies the hash. Fixes #15206 --- news/changelog-1.11.md | 1 + .../listing/website-listing-template.ts | 6 -- .../website/listing/quarto-listing.js | 50 ++++++++++------ .../tests/listing-update-handlers.spec.ts | 60 +++++++++++++++++++ 4 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 tests/integration/playwright/tests/listing-update-handlers.spec.ts diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 4e98d75086..921c665b70 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -140,6 +140,7 @@ All changes included in 1.11: - ([#15091](https://github.com/quarto-dev/quarto-cli/pull/15091)): Fix page-level `bread-crumbs`, `back-to-top-navigation`, `page-navigation`, `margin-header` and `margin-footer` options not being validated or offered for completion in editors. A page value of the wrong type now fails to render with an error instead of being accepted silently. - ([#15091](https://github.com/quarto-dev/quarto-cli/pull/15091)): Fix `open-graph: false` or `twitter-card: false` in page front matter or `_metadata.yml` not disabling Open Graph or Twitter Card metadata for that page when the website enables it. - ([#15114](https://github.com/quarto-dev/quarto-cli/pull/15114)): Fix a website category containing backticks showing broken text in the page title block, and a category containing a backslash, such as a Windows path, failing the render or losing its backslashes. +- ([#15206](https://github.com/quarto-dev/quarto-cli/issues/15206)): Fix website listings attaching another update handler on every URL hash change, and an extra one per additional listing on the same page. ## Lua API diff --git a/src/project/types/website/listing/website-listing-template.ts b/src/project/types/website/listing/website-listing-template.ts index 6d7d38e62b..e06b99998b 100644 --- a/src/project/types/website/listing/website-listing-template.ts +++ b/src/project/types/website/listing/website-listing-template.ts @@ -575,12 +575,6 @@ export function templateJsScript( window['quarto-listing-loaded'](); } }); - - window.addEventListener('hashchange',() => { - if (window['quarto-listing-loaded']) { - window['quarto-listing-loaded'](); - } - }) `; return jsScript; } diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index e5c0990247..7e2a9f4285 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -10,29 +10,41 @@ window.quartoListingCategory = (category) => { } }; -window["quarto-listing-loaded"] = () => { - // Process any existing hash +const wiredListings = new Set(); + +// Apply the category and per-listing page stored in the URL hash +function applyListingHash() { const hash = getHash(); + if (!hash || !window["quarto-listings"]) { + return; + } - if (hash) { - // If there is a category, switch to that - if (hash.category) { - // category hash are URI encoded so we need to decode it before processing - // so that we can match it with the category element processed in JS - activateCategory(decodeURIComponent(hash.category)); - } - // Paginate a specific listing - const listingIds = Object.keys(window["quarto-listings"]); - for (const listingId of listingIds) { - const page = hash[getListingPageKey(listingId)]; - if (page) { - showPage(listingId, page); - } + // If there is a category, switch to that + if (hash.category) { + // category hash are URI encoded so we need to decode it before processing + // so that we can match it with the category element processed in JS + activateCategory(decodeURIComponent(hash.category)); + } + // Paginate a specific listing + for (const listingId of Object.keys(window["quarto-listings"])) { + const page = hash[getListingPageKey(listingId)]; + if (page) { + showPage(listingId, page); } } +} + +window["quarto-listing-loaded"] = () => { + // Process any existing hash + applyListingHash(); + + // Each listing calls this once its List is created, so wire each one once + for (const listingId of Object.keys(window["quarto-listings"])) { + if (wiredListings.has(listingId)) { + continue; + } + wiredListings.add(listingId); - const listingIds = Object.keys(window["quarto-listings"]); - for (const listingId of listingIds) { // The actual list const list = window["quarto-listings"][listingId]; @@ -56,6 +68,8 @@ window["quarto-listing-loaded"] = () => { } }; +window.addEventListener("hashchange", applyListingHash); + window.document.addEventListener("DOMContentLoaded", function (_event) { // Attach click handlers to categories const categoryEls = window.document.querySelectorAll( diff --git a/tests/integration/playwright/tests/listing-update-handlers.spec.ts b/tests/integration/playwright/tests/listing-update-handlers.spec.ts new file mode 100644 index 0000000000..ff610626a5 --- /dev/null +++ b/tests/integration/playwright/tests/listing-update-handlers.spec.ts @@ -0,0 +1,60 @@ +import { expect, Page, test } from "@playwright/test"; + +const fixture = "./listings/interactions/_site/index.html"; +const listingIds = ["first-listing", "second-listing"]; + +async function changeHash(page: Page, hash: string) { + await page.evaluate((hash) => + new Promise((resolve) => { + window.addEventListener("hashchange", () => resolve(), { once: true }); + window.location.hash = hash; + }), hash); +} + +// The update handler calls window.quartoDecorateExternalLinks with the +// listing's list element; counting those calls per listing counts the runs. +async function countUpdateHandlerRuns(page: Page) { + await page.evaluate(() => { + const w = window as any; + const decorate = w.quartoDecorateExternalLinks; + w.updateRuns = {}; + w.quartoDecorateExternalLinks = (root: Element) => { + const id = root?.closest?.(".quarto-listing")?.id; + if (id) w.updateRuns[id] = (w.updateRuns[id] ?? 0) + 1; + return decorate(root); + }; + }); +} + +// A pagination click updates the list twice (list.js and Quarto both handle +// it), so trigger exactly one update through the List.js API instead. +async function updateRunsForOneUpdate(page: Page, listingId: string) { + return page.evaluate((id) => { + const w = window as any; + w.updateRuns = {}; + w["quarto-listings"][id].update(); + return w.updateRuns[id]; + }, `listing-${listingId}`); +} + +test.describe("Listing update handlers", () => { + for (const listingId of listingIds) { + test(`${listingId} runs its update handler once per update`, async ({ page }) => { + await page.goto(fixture); + await countUpdateHandlerRuns(page); + + expect(await updateRunsForOneUpdate(page, listingId)).toBe(1); + }); + + test(`${listingId} keeps one update handler across hash changes`, async ({ page }) => { + await page.goto(fixture); + await countUpdateHandlerRuns(page); + + for (const hash of ["one", "two", "three"]) { + await changeHash(page, hash); + } + + expect(await updateRunsForOneUpdate(page, listingId)).toBe(1); + }); + } +});