Stop a listing page from rewriting the next one's grid after navigation. #11

Merged
Michilis merged 1 commits from dev into main 2026-09-10 18:29:14 +00:00
6 changed files with 79 additions and 6 deletions
Showing only changes of commit ae7664fbe0 - Show all commits
+4
View File
@@ -302,6 +302,10 @@ export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
const all = await fetchListing(type); const all = await fetchListing(type);
if (!all) return; 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 items = limit === undefined ? all : all.slice(0, limit);
const cards = renderMintGrid(grid, items, { const cards = renderMintGrid(grid, items, {
+2 -2
View File
@@ -138,7 +138,7 @@ const schema = [
) )
} }
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="fedimint">
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)} {federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
</div> </div>
@@ -259,7 +259,7 @@ const schema = [
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="fedimint"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+2 -2
View File
@@ -142,7 +142,7 @@ const schema = [
{t('lnurlMints.showing', { total: mints.length, online, offline })} {t('lnurlMints.showing', { total: mints.length, online, offline })}
</p> </p>
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="lnurl">
{mints.map((mint, i) => ( {mints.map((mint, i) => (
<MintCard <MintCard
mint={mint} mint={mint}
@@ -272,7 +272,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too. // `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="lnurl"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+2 -2
View File
@@ -125,7 +125,7 @@ const schema = [
{t('mints.showing', { total: mints.length, online, offline })} {t('mints.showing', { total: mints.length, online, offline })}
</p> </p>
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="cashu">
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)} {mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
</div> </div>
@@ -244,7 +244,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too. // `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="cashu"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+9
View File
@@ -208,6 +208,15 @@ export function armSharedTransitions(): void {
* only does its work at module scope is dead after the first navigation. It does fire * 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 * `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. * 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 { export function onReady(setup: () => void): void {
let done = false; let done = false;
+60
View File
@@ -0,0 +1,60 @@
/**
* The three listing pages must each own their grid.
*
* `<ClientRouter />` 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(`<div class="mint-grid" data-mint-grid="${type}">`),
`${file}: template grid is not marked data-mint-grid="${type}"`,
);
assert.match(
source,
new RegExp(`querySelector<HTMLElement>\\('\\[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);
});