Skip to content

Commit 3d14a60

Browse files
ralyodioclaude
andauthored
fix(pit): stop /pit rendering the entire namespace at once (#167)
The page locks browsers up and there is no script on it to blame. It drew every ending the account holds, and under each one a form per name — CSRF field, two inputs, two buttons — with no limit on either end. This registry already holds 200 endings under one account. Measured at 50 endings x 100 names: 3.1 MiB of HTML and 36,082 elements. Nothing has to be slow for that to jam; it is the DOM, and the sticky blurred app bar repaints over all of it on every scroll frame. Same data now renders 173 KiB and 1,926 elements — 20 endings a page, 10 names each, with the totals stated so a window is never mistaken for the whole. `?tld=` opens one ending in full, which is where the "show all N" links go, and where TronBrowser's mosh.<tld> console link already pointed at a page that ignored the parameter and drew everything anyway. Paging orders by `created_at DESC, tld` rather than `created_at` alone. A bulk claim writes one timestamp across every ending in it, so the old sort was not a total order: a page boundary inside a tie would repeat one ending and lose another. Covered by a test that ties every timestamp on purpose. Only the endings on screen are queried now — this used to run one listNames per ending held, regardless of what it was about to render. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d36a0ac commit 3d14a60

3 files changed

Lines changed: 305 additions & 20 deletions

File tree

apps/pwa/src/moshpit.mjs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,28 @@ export async function listTlds(limit = 200) {
3939
return all(`SELECT ${COLS} FROM moshpit_tlds ORDER BY created_at DESC LIMIT ?`, [limit]);
4040
}
4141

42-
export async function listTldsForUser(userId) {
43-
return all(`SELECT ${COLS} FROM moshpit_tlds WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
42+
/**
43+
* The endings one account holds.
44+
*
45+
* `limit`/`offset` page it. They are optional because the JSON API hands the
46+
* whole list back and a list of strings costs nothing -- it is /pit that cannot
47+
* afford it, because every ending it draws brings a form per name with it.
48+
*
49+
* Ordered by `created_at DESC, tld` rather than `created_at DESC` alone. A bulk
50+
* claim writes one timestamp across every ending in it, so `created_at` is not
51+
* a total order, and a page boundary landing inside a tie would show the same
52+
* ending twice on one page and skip another entirely.
53+
*/
54+
export async function listTldsForUser(userId, { limit = null, offset = 0 } = {}) {
55+
const page = limit === null ? "" : ` LIMIT ? OFFSET ?`;
56+
const args = limit === null ? [userId] : [userId, limit, offset];
57+
return all(`SELECT ${COLS} FROM moshpit_tlds WHERE user_id = ? ORDER BY created_at DESC, tld${page}`, args);
58+
}
59+
60+
/** How many endings the account holds -- for the pager, which needs the total. */
61+
export async function countTldsForUser(userId) {
62+
const row = await get(`SELECT COUNT(*) AS n FROM moshpit_tlds WHERE user_id = ?`, [userId]);
63+
return Number(row?.n ?? 0);
4464
}
4565

4666
/**
@@ -205,6 +225,17 @@ export async function listNames(tld, limit = 500) {
205225
return all(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? ORDER BY label LIMIT ?`, [tld, limit]);
206226
}
207227

228+
/**
229+
* How many names live under an ending.
230+
*
231+
* /pit draws a handful of them per ending and has to say how many it is not
232+
* drawing -- "12 shown" with no total reads as "you have 12 names".
233+
*/
234+
export async function countNames(tld) {
235+
const row = await get(`SELECT COUNT(*) AS n FROM moshpit_names WHERE tld = ?`, [tld]);
236+
return Number(row?.n ?? 0);
237+
}
238+
208239
export async function listNamesForUser(userId) {
209240
return all(`SELECT ${NAME_COLS} FROM moshpit_names WHERE user_id = ? ORDER BY tld, label`, [userId]);
210241
}
@@ -317,13 +348,28 @@ export async function setTldPrice({ tld: tldInput, userId, priceUsd }) {
317348
return { ok: true, tld, priceUsd: price };
318349
}
319350

320-
/** TLDs somebody else holds. `forSale` narrows to the ones actually buyable. */
321-
export async function listTldsNotOwnedBy(userId, { forSale = false, limit = 200 } = {}) {
351+
/**
352+
* TLDs somebody else holds. `forSale` narrows to the ones actually buyable.
353+
*
354+
* `tld` breaks the tie for the same reason it does in listTldsForUser: a bulk
355+
* claim shares one timestamp, and paging through a partial order loses rows.
356+
*/
357+
export async function listTldsNotOwnedBy(userId, { forSale = false, limit = 200, offset = 0 } = {}) {
322358
const sql = `SELECT tld, user_id, owner_email, alias_of, price_usd, created_at
323359
FROM moshpit_tlds
324360
WHERE user_id IS NOT ?${forSale ? " AND price_usd IS NOT NULL" : ""}
325-
ORDER BY price_usd IS NULL, created_at DESC LIMIT ?`;
326-
return all(sql, [userId ?? "", limit]);
361+
ORDER BY price_usd IS NULL, created_at DESC, tld LIMIT ? OFFSET ?`;
362+
return all(sql, [userId ?? "", limit, offset]);
363+
}
364+
365+
/** How many endings somebody else holds -- the Theirs pager needs the total. */
366+
export async function countTldsNotOwnedBy(userId, { forSale = false } = {}) {
367+
const row = await get(
368+
`SELECT COUNT(*) AS n FROM moshpit_tlds
369+
WHERE user_id IS NOT ?${forSale ? " AND price_usd IS NOT NULL" : ""}`,
370+
[userId ?? ""],
371+
);
372+
return Number(row?.n ?? 0);
327373
}
328374

329375
export async function getTldWithPrice(tld) {

apps/pwa/src/routes/moshpit.mjs

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import {
2525
addPin,
2626
clearAlias,
2727
clearExempt,
28+
countNames,
29+
countTldsForUser,
30+
countTldsNotOwnedBy,
2831
DEFAULT_TLD_PRICE_USD,
2932
getName,
3033
getTld,
@@ -774,6 +777,8 @@ const PIT_CSS = `
774777
font-size:.8rem;line-height:1.55;resize:vertical;min-height:9em}
775778
.pit-bulk textarea:focus{outline:none;border-color:var(--acid)}
776779
.pit-tabs{display:flex;gap:4px;margin:22px 0 26px;border-bottom:1px solid var(--line)}
780+
.pit-pager{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:22px 0 4px;font-size:.72rem}
781+
.pit-pager .btn[aria-disabled]{opacity:.4;pointer-events:none}
777782
.pit-tab{font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
778783
padding:11px 15px;border-bottom:2px solid transparent;margin-bottom:-1px}
779784
.pit-tab:hover{color:var(--text)}
@@ -814,12 +819,67 @@ const pitTabs = (active, counts = null) => `
814819

815820
const forSale = (t) => t.price_usd !== null && t.price_usd !== undefined;
816821

822+
/**
823+
* How much of the namespace one page may draw.
824+
*
825+
* /pit ships no script at all, and it still locked browsers up: the page grew
826+
* as endings x names-under-them, and neither end was bounded. Every name is a
827+
* form with a CSRF field, two inputs and two buttons, so an account holding 50
828+
* endings with 100 names each rendered 3 MiB of HTML and 36k elements. Nothing
829+
* has to be slow for that to jam -- it is the DOM, and the sticky blurred app
830+
* bar repainting over it on every scroll frame.
831+
*
832+
* So the page shows a window and says what it is not showing. `?tld=` opens one
833+
* ending in full, which is also where the "show all N" links go -- and where
834+
* TronBrowser's `mosh.<tld>` already pointed, on a page that until now ignored
835+
* the parameter and drew everything anyway.
836+
*/
837+
const TLDS_PER_PAGE = 20;
838+
const NAMES_PER_TLD = 10;
839+
const NAMES_FOCUSED = 250;
840+
841+
/** `?page=` as a 1-based page number; anything unreadable is page 1. */
842+
const pageParam = (value) => {
843+
const n = Number.parseInt(value, 10);
844+
return Number.isFinite(n) && n > 1 ? n : 1;
845+
};
846+
847+
/** Prev/next for a window into `total` rows, or nothing when it all fits. */
848+
const pager = ({ page, total, perPage, href }) => {
849+
const pages = Math.max(1, Math.ceil(total / perPage));
850+
if (pages <= 1) return "";
851+
const link = (n, label) => `<a class="btn" href="${href(n)}">${label}</a>`;
852+
return `<nav class="pit-pager">
853+
${page > 1 ? link(page - 1, "← Newer") : `<span class="btn faint" aria-disabled="true">← Newer</span>`}
854+
<span class="mono faint">page ${page} of ${pages} · ${total} endings</span>
855+
${page < pages ? link(page + 1, "Older →") : `<span class="btn faint" aria-disabled="true">Older →</span>`}
856+
</nav>`;
857+
};
858+
817859
moshpitRouter.get("/pit", async (req, res) => {
818-
const [theirs, mine, bal] = await Promise.all([
819-
listTldsNotOwnedBy(req.user?.id ?? null, { limit: 100 }),
820-
req.user ? listTldsForUser(req.user.id) : [],
860+
// An unknown ?tab= falls back to Yours rather than rendering an empty page.
861+
const tab = req.query.tab === "theirs" ? "theirs" : "yours";
862+
const pageNo = pageParam(req.query.page);
863+
864+
// `?tld=` opens a single ending in full. Only meaningful for one you hold --
865+
// Theirs is one row per ending and has nothing to expand.
866+
const wanted = normalizeTld(req.query.tld) || null;
867+
const focused = tab === "yours" && req.user && wanted
868+
? await getTld(wanted).then((t) => (t && t.user_id === req.user.id ? t : null))
869+
: null;
870+
871+
const [theirs, theirsTotal, mine, mineTotal, bal] = await Promise.all([
872+
tab === "theirs"
873+
? listTldsNotOwnedBy(req.user?.id ?? null, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE })
874+
: [],
875+
countTldsNotOwnedBy(req.user?.id ?? null),
876+
req.user && !focused
877+
? listTldsForUser(req.user.id, { limit: TLDS_PER_PAGE, offset: (pageNo - 1) * TLDS_PER_PAGE })
878+
: [],
879+
req.user ? countTldsForUser(req.user.id) : 0,
821880
req.user ? balance(req.user.id) : 0,
822881
]);
882+
const shown = focused ? [focused] : mine;
823883

824884
// `?name=mosh.whatever` — somebody typed a Moshpit name and ended up here
825885
// instead of at a site. Work out what they can actually do about it.
@@ -839,18 +899,22 @@ moshpitRouter.get("/pit", async (req, res) => {
839899
});
840900
}
841901

842-
// An unknown ?tab= falls back to Yours rather than rendering an empty page.
843-
const tab = req.query.tab === "theirs" ? "theirs" : "yours";
844-
const forSaleCount = theirs.filter(forSale).length;
902+
const forSaleCount = await countTldsNotOwnedBy(req.user?.id ?? null, { forSale: true });
845903

846-
// Per-TLD detail is only needed by the panel actually on screen, and only
847-
// Yours has any: Theirs is one row per ending.
904+
// Per-TLD detail is only needed by the endings actually on screen, and only
905+
// Yours has any: Theirs is one row per ending. That is the whole fix -- this
906+
// used to run one listNames per ending the account held, however many that
907+
// was, and then render every row it got back.
848908
const exemptions = new Map();
849909
const names = new Map();
910+
const nameTotals = new Map();
850911
if (tab === "yours") {
851912
// Exemptions are only meaningful for a TLD that points somewhere.
852-
await Promise.all(mine.filter((t) => t.alias_of).map(async (t) => exemptions.set(t.tld, await listExempt(t.tld))));
853-
await Promise.all(mine.map(async (t) => names.set(t.tld, await listNames(t.tld))));
913+
await Promise.all(shown.filter((t) => t.alias_of).map(async (t) => exemptions.set(t.tld, await listExempt(t.tld))));
914+
await Promise.all(shown.map(async (t) => {
915+
names.set(t.tld, await listNames(t.tld, focused ? NAMES_FOCUSED : NAMES_PER_TLD));
916+
nameTotals.set(t.tld, await countNames(t.tld));
917+
}));
854918
}
855919

856920
const msg = req.query.err ? `<p class="pit-msg err">${esc(req.query.err)}</p>`
@@ -859,12 +923,13 @@ moshpitRouter.get("/pit", async (req, res) => {
859923
const mineHtml = !req.user
860924
? `<p class="dim">Sign in with your moshcode account to claim one — the same login the CLI uses.</p>
861925
<p><a class="btn acid" href="/">Sign in →</a></p>`
862-
: mine.length
863-
? mine.map((t) => `
926+
: shown.length
927+
? shown.map((t) => `
864928
<div class="pit-tld">
865929
<h3 class="acid">.${esc(t.tld)}</h3>
866930
<div class="mono faint" style="font-size:.72rem">
867931
${t.alias_of ? `points at <span class="acid">.${esc(t.alias_of)}</span>` : "stands on its own"}
932+
· ${nameTotals.get(t.tld) ?? 0} name${(nameTotals.get(t.tld) ?? 0) === 1 ? "" : "s"}
868933
</div>
869934
<div class="pit-names">
870935
${(names.get(t.tld) || []).length
@@ -878,6 +943,12 @@ moshpitRouter.get("/pit", async (req, res) => {
878943
<button class="btn" type="submit" name="release" value="1">Release</button>
879944
</form>`).join("")
880945
: `<p class="mono faint" style="font-size:.72rem;margin:6px 0">no names under .${esc(t.tld)} yet</p>`}
946+
${(nameTotals.get(t.tld) ?? 0) > (names.get(t.tld) || []).length ? `
947+
<p class="mono faint" style="font-size:.72rem;margin:6px 0">
948+
${(names.get(t.tld) || []).length} of ${nameTotals.get(t.tld)} shown${focused
949+
? ` — this ending holds more than the ${NAMES_FOCUSED} a page will draw`
950+
: ` · <a class="acid" href="/pit?tld=${encodeURIComponent(t.tld)}">open .${esc(t.tld)} on its own →</a>`}
951+
</p>` : ""}
881952
<form method="post" action="/pit/${esc(t.tld)}/names" class="pit-row">
882953
${csrfInput(req)}
883954
<input name="label" placeholder="new name" autocomplete="off" required>
@@ -955,22 +1026,35 @@ moshpitRouter.get("/pit", async (req, res) => {
9551026
${landingCard(req, landing)}
9561027
${msg}
9571028
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
958-
${pitTabs(tab, { yours: mine.length, theirs: theirs.length, forSale: forSaleCount })}
1029+
${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: forSaleCount })}
9591030
9601031
<section class="pit-panel">
9611032
${tab === "yours" ? `
1033+
${focused ? `
1034+
<p class="dim" style="max-width:62ch;margin:0 0 14px">
1035+
<a class="acid" href="/pit">← all ${mineTotal} of your endings</a> · showing
1036+
<span class="mono acid">.${esc(focused.tld)}</span> on its own.
1037+
</p>` : `
9621038
<p class="dim" style="max-width:62ch;margin:0 0 14px">
9631039
Endings you hold. Names under them are yours to mint for nothing — or put a price on the
9641040
ending and let anyone buy one.
965-
</p>
1041+
</p>`}
9661042
${mineHtml}
1043+
${focused ? "" : pager({
1044+
page: pageNo, total: mineTotal, perPage: TLDS_PER_PAGE,
1045+
href: (n) => `/pit?tab=yours&page=${n}`,
1046+
})}
9671047
` : `
9681048
<p class="dim" style="max-width:62ch;margin:0 0 14px">
9691049
Endings somebody else holds. Where the operator has set a price you can buy a name under it —
9701050
<span class="mono">foo.whatever</span> without owning <span class="mono">.whatever</span>. Paid in crypto
9711051
through CoinPay; the name lands the moment the payment confirms.
9721052
</p>
9731053
${theirsHtml}
1054+
${pager({
1055+
page: pageNo, total: theirsTotal, perPage: TLDS_PER_PAGE,
1056+
href: (n) => `/pit?tab=theirs&page=${n}`,
1057+
})}
9741058
`}
9751059
</section>
9761060
</main>${footer}`,

0 commit comments

Comments
 (0)