Skip to content

Commit 1530a39

Browse files
ralyodioclaude
andcommitted
fix(pit): stop /pit rendering the entire namespace at once
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 06bd138 commit 1530a39

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)