fix(frontend): bound Call-to-Play projection work

A valid remote view could make the renderer reduce and mount thousands of calls
every second. A single long chat also re-sorted its growing message array after
every append during every reduction.

Keep only the highest-ranked 128 nominations and latest 256 messages for UI
projection. Events are sorted once before folding, so chat messages remain in
stable order without repeated growing-array sorts. Backend author history and
creator authority are unchanged.

Test Plan:
- `just frontend-test` -- passed; 94 frontend tests passed.
- `just fmt` -- TypeScript formatting completed; the recipe then hit the
  pre-existing generated security-report Markdown-lint failures.
- `git diff --cached --check` -- passed.
This commit is contained in:
2026-09-12 12:28:25 +02:00
parent 30663fea34
commit 078294ac83
2 changed files with 42 additions and 2 deletions
@@ -9,6 +9,8 @@ import {
export const CHECKIN_LEAD_MS = 15 * 60_000;
export const EXPIRED_RETENTION_MS = 5 * 60_000;
export const TERMINAL_RETENTION_MS = 15 * 60_000;
export const MAX_VISIBLE_CALL_TO_PLAY_NOMINATIONS = 128;
export const MAX_VISIBLE_CALL_TO_PLAY_MESSAGES = 256;
export const CALL_TO_PLAY_CONNECTING_MESSAGE =
'Call to Play is still connecting to the LAN. Try again in a moment.';
@@ -138,7 +140,7 @@ export const reduceCallToPlayEvents = (
const nominations = [...groupEvents(input).values()]
.map(events => deriveNomination(events, now))
.filter((nomination): nomination is Nomination => nomination !== null);
return sortNominations(nominations);
return sortNominations(nominations).slice(0, MAX_VISIBLE_CALL_TO_PLAY_NOMINATIONS);
};
const groupEvents = (
@@ -189,6 +191,9 @@ const deriveNomination = (
for (const event of events) {
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
}
if (nomination.messages.length > MAX_VISIBLE_CALL_TO_PLAY_MESSAGES) {
nomination.messages = nomination.messages.slice(-MAX_VISIBLE_CALL_TO_PLAY_MESSAGES);
}
if (nomination.state === 'open'
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
@@ -237,7 +242,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent):
text: message.text,
at: event.at,
});
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
return;
}
@@ -2,6 +2,8 @@ import {
CALL_TO_PLAY_CONNECTING_MESSAGE,
CHECKIN_LEAD_MS,
EXPIRED_RETENTION_MS,
MAX_VISIBLE_CALL_TO_PLAY_MESSAGES,
MAX_VISIBLE_CALL_TO_PLAY_NOMINATIONS,
TERMINAL_RETENTION_MS,
activeCallCount,
callToPlayPublishErrorMessage,
@@ -291,6 +293,40 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => {
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('large Call to Play views are bounded to the ranked UI subset', () => {
const events = Array.from({ length: MAX_VISIBLE_CALL_TO_PLAY_NOMINATIONS + 1 }, (_, index) => ({
...create(),
id: `create-${index}`,
call_id: `call-${index}`,
}));
assertEquals(
reduceCallToPlayEvents(events, NOW).length,
MAX_VISIBLE_CALL_TO_PLAY_NOMINATIONS,
'rendered nominations must stay within the UI budget',
);
});
Deno.test('one call exposes only the latest bounded chat history', () => {
const messages = Array.from({ length: MAX_VISIBLE_CALL_TO_PLAY_MESSAGES + 1 }, (_, index) =>
event(
`message-${index}`,
'Bob',
{ SendMessage: { text: `message ${index}` } },
NOW + index + 1,
)
);
const [nomination] = reduceCallToPlayEvents([create(), ...messages], NOW + messages.length + 1);
assertEquals(
nomination.messages.length,
MAX_VISIBLE_CALL_TO_PLAY_MESSAGES,
'visible messages must stay within the UI budget',
);
assertEquals(nomination.messages[0].text, 'message 1', 'oldest excess message is removed');
});
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
const incoming = { events: [create()] };