From ae7664fbe0d0e4f0de1094ff63ba5b454e0b3364 Mon Sep 17 00:00:00 2001 From: michilis Date: Mon, 7 Sep 2026 22:36:26 +0200 Subject: [PATCH] Stop a listing page from rewriting the next one's grid after navigation. Switching between /mints, /fedimints and /lnurl-mints showed the right list for a moment and then flashed back to the previous ecosystem's mints. The client router keeps every page's script alive, and onReady re-runs each setup on every arrival, so a visited page's setup also ran on the next page. All three grids were marked with the same bare data-mint-grid, so the stale setup found the new grid, fetched its own type and overwrote it. Mark and query each grid by ecosystem (data-mint-grid="cashu" etc.), the way the home page already scopes data-home-grid, so a stale setup finds nothing and bails. Skip the render in hydrateMintGrid when the grid has already been detached by the router. Document the re-run-everywhere contract on onReady, and add a static wiring test so a bare marker cannot come back. Co-Authored-By: Claude Fable 5.1 --- web/src/lib/mint-cards.ts | 4 ++ web/src/pages/[...locale]/fedimints.astro | 4 +- web/src/pages/[...locale]/lnurl-mints.astro | 4 +- web/src/pages/[...locale]/mints.astro | 4 +- web/src/scripts/reveal.ts | 9 ++++ web/test/listing-wiring.test.mjs | 60 +++++++++++++++++++++ 6 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 web/test/listing-wiring.test.mjs diff --git a/web/src/lib/mint-cards.ts b/web/src/lib/mint-cards.ts index ed85af8..3d2a621 100644 --- a/web/src/lib/mint-cards.ts +++ b/web/src/lib/mint-cards.ts @@ -302,6 +302,10 @@ export async function hydrateMintGrid(options: HydrateOptions): Promise { const all = await fetchListing(type); if (!all) return; + // The reader may have navigated away while the fetch was in flight. The router has + // already swapped this grid out of the document, so rendering into it (and telling the + // caller to re-apply filters against cards nobody can see) is wasted work. + if (!grid.isConnected) return; const items = limit === undefined ? all : all.slice(0, limit); const cards = renderMintGrid(grid, items, { diff --git a/web/src/pages/[...locale]/fedimints.astro b/web/src/pages/[...locale]/fedimints.astro index 4ce105b..48c13ef 100644 --- a/web/src/pages/[...locale]/fedimints.astro +++ b/web/src/pages/[...locale]/fedimints.astro @@ -138,7 +138,7 @@ const schema = [ ) } -
+
{federations.map((federation, i) => )}
@@ -259,7 +259,7 @@ const schema = [ const t = useI18n(); - const grid = document.querySelector('[data-mint-grid]'); + const grid = document.querySelector('[data-mint-grid="fedimint"]'); const searchInput = document.querySelector('[data-search-input]'); const sortSelect = document.querySelector('[data-sort]'); const hideOffline = document.querySelector('[data-hide-offline]'); diff --git a/web/src/pages/[...locale]/lnurl-mints.astro b/web/src/pages/[...locale]/lnurl-mints.astro index 18c3e23..fec0f65 100644 --- a/web/src/pages/[...locale]/lnurl-mints.astro +++ b/web/src/pages/[...locale]/lnurl-mints.astro @@ -142,7 +142,7 @@ const schema = [ {t('lnurlMints.showing', { total: mints.length, online, offline })}

-
+
{mints.map((mint, i) => ( ('[data-mint-grid]'); + const grid = document.querySelector('[data-mint-grid="lnurl"]'); const searchInput = document.querySelector('[data-search-input]'); const sortSelect = document.querySelector('[data-sort]'); const hideOffline = document.querySelector('[data-hide-offline]'); diff --git a/web/src/pages/[...locale]/mints.astro b/web/src/pages/[...locale]/mints.astro index 89f6c45..b46f9d0 100644 --- a/web/src/pages/[...locale]/mints.astro +++ b/web/src/pages/[...locale]/mints.astro @@ -125,7 +125,7 @@ const schema = [ {t('mints.showing', { total: mints.length, online, offline })}

-
+
{mints.map((mint, i) => )}
@@ -244,7 +244,7 @@ const schema = [ // `t` formats its own numbers, so the grouping separator follows the locale too. const t = useI18n(); - const grid = document.querySelector('[data-mint-grid]'); + const grid = document.querySelector('[data-mint-grid="cashu"]'); const searchInput = document.querySelector('[data-search-input]'); const sortSelect = document.querySelector('[data-sort]'); const hideOffline = document.querySelector('[data-hide-offline]'); diff --git a/web/src/scripts/reveal.ts b/web/src/scripts/reveal.ts index bb939cf..3a6dade 100644 --- a/web/src/scripts/reveal.ts +++ b/web/src/scripts/reveal.ts @@ -208,6 +208,15 @@ export function armSharedTransitions(): void { * only does its work at module scope is dead after the first navigation. It does fire * `astro:page-load` on every arrival, including the first, but on the first it waits * for `window.load`, which is far too late to be the only trigger. Hence both. + * + * The listener is never removed, on purpose: a layout island (the language switcher, + * the login dialog) has DOM on every page, and this is what keeps it alive. The flip + * side is that `setup` runs on *every* page the router swaps in, not only pages that + * include the script. A page-level script must therefore look its DOM up by a marker + * unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do + * nothing when the marker is absent. A marker shared between pages means a stale page's + * setup finds the current page's DOM and rewrites it: the listing pages once flashed + * back to the previous ecosystem's mints for exactly that reason. */ export function onReady(setup: () => void): void { let done = false; diff --git a/web/test/listing-wiring.test.mjs b/web/test/listing-wiring.test.mjs new file mode 100644 index 0000000..5e26b78 --- /dev/null +++ b/web/test/listing-wiring.test.mjs @@ -0,0 +1,60 @@ +/** + * The three listing pages must each own their grid. + * + * `` swaps pages without reloading, and `onReady` in `scripts/reveal.ts` + * re-runs every page's setup on every arrival — deliberately, since that is what keeps + * the layout islands alive. So once `/mints` has been visited, its setup also runs on + * `/fedimints`. If both pages mark their grid the same way, the stale cashu setup finds + * the fedimint grid, fetches `?type=cashu` and rewrites it: the page shows fedimints for + * a moment, then flashes back to cashu mints. That happened. + * + * The guard is that each page's marker carries its ecosystem, and its script only looks + * for its own. This checks the template, the query and the hydrate call agree, and that + * no bare `data-mint-grid` has crept back in. Plain node, source read as text, like + * `i18n-wiring.test.mjs`. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/pages/[...locale]'); +const read = (file) => readFileSync(path.join(pagesDir, file), 'utf8'); + +const LISTINGS = [ + ['mints.astro', 'cashu'], + ['fedimints.astro', 'fedimint'], + ['lnurl-mints.astro', 'lnurl'], +]; + +for (const [file, type] of LISTINGS) { + test(`${file} marks, queries and hydrates its grid as ${type}`, () => { + const source = read(file); + + assert.match( + source, + new RegExp(`
`), + `${file}: template grid is not marked data-mint-grid="${type}"`, + ); + assert.match( + source, + new RegExp(`querySelector\\('\\[data-mint-grid="${type}"\\]'\\)`), + `${file}: script does not query [data-mint-grid="${type}"]`, + ); + assert.match( + source, + new RegExp(`type: '${type}',`), + `${file}: hydrateMintGrid is not called with type '${type}'`, + ); + + // A bare marker, in either the template or the query, is the bug coming back. + assert.doesNotMatch(source, /data-mint-grid[>\s]/, `${file}: bare data-mint-grid in template`); + assert.doesNotMatch(source, /\[data-mint-grid\]/, `${file}: bare [data-mint-grid] selector`); + }); +} + +test('the three listings use three different markers', () => { + const types = new Set(LISTINGS.map(([, type]) => type)); + assert.equal(types.size, LISTINGS.length); +});