atagen

malice

public
Star0
Watch0

Commit a802a95814e6

tarball

atagen <boss@atagen.co>2026-10-26 03:48 UTC

8 files changed+405-35
web: reuse unchanged timeline items and coalesce worker bursts

Each timeline change resent and reparsed the whole room into new objects, so a single reaction or receipt rerendered every row. Unchanged items, rooms and thread lists now keep their identity, and full-state worker events are coalesced per key and macrotask while deltas keep their order.
Modified file: web/src/lib/arrivals.ts+14 additions−3 deletions
@@ -2,6 +2,16 @@ import type { TimelineItem } from './types';
22
33 export const FEDERATION_LAG_MS = 60 * 1000;
44
5+const decorated = new WeakMap<TimelineItem, { lag: number; item: TimelineItem }>();
6+
7+function withLag(item: TimelineItem & { kind: 'event' }, lag: number): TimelineItem {
8+ const hit = decorated.get(item);
9+ if (hit && hit.lag === lag) return hit.item;
10+ const next: TimelineItem = { ...item, event: { ...item.event, delivery_lag_ms: lag } };
11+ decorated.set(item, { lag, item: next });
12+ return next;
13+}
14+
515 /**
616 * Carries delivery lag forward from the previous list and measures it for events appended
717 * after the last one already known. `since` is when the room last became able to receive
@@ -16,15 +26,16 @@ export function observeArrivals(previous: TimelineItem[], items: TimelineItem[],
1626 if (item.kind === 'event' && lags.has(item.event.item_id)) lastKnown = index;
1727 });
1828 if (lastKnown === -1) return null;
19- return items.map((item, index) => {
29+ const next = items.map((item, index) => {
2030 if (item.kind !== 'event') return item;
2131 const event = item.event;
2232 if (lags.has(event.item_id)) {
2333 const carried = lags.get(event.item_id);
24- return carried === undefined ? item : { ...item, event: { ...event, delivery_lag_ms: carried } };
34+ return carried === undefined ? item : withLag(item, carried);
2535 }
2636 if (index < lastKnown || event.own) return item;
2737 const lag = now - Math.max(event.timestamp_ms, since);
28- return lag < FEDERATION_LAG_MS ? item : { ...item, event: { ...event, delivery_lag_ms: lag } };
38+ return lag < FEDERATION_LAG_MS ? item : withLag(item, lag);
2939 });
40+ return previous.length === next.length && next.every((item, index) => item === previous[index]) ? previous : next;
3041 }
Added file: web/src/lib/sharing.ts+63 additions
@@ -0,0 +1,63 @@
1+interface Entry<T> {
2+ signature: string;
3+ item: T;
4+}
5+
6+export interface ShareCache<T> {
7+ byKey: Map<string, Entry<T>>;
8+ list: T[];
9+}
10+
11+export function newShareCache<T>(): ShareCache<T> {
12+ return { byKey: new Map(), list: [] };
13+}
14+
15+function signature(text: string): string {
16+ let a = 0x811c9dc5;
17+ let b = 0x9747b28c;
18+ for (let i = 0; i < text.length; i += 1) {
19+ const c = text.charCodeAt(i);
20+ a = Math.imul(a ^ c, 0x01000193);
21+ b = Math.imul(b + c, 0x85ebca6b) ^ (b >>> 13);
22+ }
23+ return `${text.length}.${(a >>> 0).toString(36)}.${(b >>> 0).toString(36)}`;
24+}
25+
26+/**
27+ * Parses `raw` while reusing the objects from the previous call whose serialised form is unchanged.
28+ * Entries are matched by `identify` and compared by a hash of their JSON, so only the latest list is retained.
29+ * Returns the previous array itself when every element was reused and the length matches.
30+ */
31+export function parseShared<T>(
32+ cache: ShareCache<T>,
33+ raw: unknown,
34+ parse: (entry: unknown) => T | null,
35+ identify: (entry: unknown) => string | null = () => null
36+): T[] {
37+ if (!Array.isArray(raw)) {
38+ cache.byKey = new Map();
39+ cache.list = cache.list.length ? [] : cache.list;
40+ return cache.list;
41+ }
42+ const byKey = new Map<string, Entry<T>>();
43+ const out: T[] = [];
44+ for (const entry of raw) {
45+ const sig = signature(JSON.stringify(entry));
46+ const base = identify(entry) ?? `~${sig}`;
47+ let key = base;
48+ for (let n = 1; byKey.has(key); n += 1) key = `${base}#${n}`;
49+ const previous = cache.byKey.get(key);
50+ const item = previous && previous.signature === sig ? previous.item : parse(entry);
51+ if (item === null) continue;
52+ byKey.set(key, { signature: sig, item });
53+ out.push(item);
54+ }
55+ cache.byKey = byKey;
56+ if (out.length === cache.list.length && out.every((item, index) => item === cache.list[index])) return cache.list;
57+ cache.list = out;
58+ return out;
59+}
60+
61+export function sameList<T>(a: readonly T[], b: readonly T[]): boolean {
62+ return a === b || (a.length === b.length && a.every((item, index) => item === b[index]));
63+}
Modified file: web/src/lib/store.ts+53 additions−12 deletions
@@ -1,10 +1,13 @@
11 import { get, writable } from 'svelte/store';
2+import type { Writable } from 'svelte/store';
23 import { parseMentionsPage, parseMentionsUpdate } from './mentions-model';
34 import type { MentionsPage } from './mentions-model';
45 import { getSession, subscribeEvents } from './api';
56 import type { SoftLogout } from './api';
67 import { isRecord } from './guards';
78 import { observeArrivals } from './arrivals';
9+import { newShareCache, parseShared, sameList } from './sharing';
10+import type { ShareCache } from './sharing';
811 import { loadKeywords, resetKeywords } from './keywords';
912 import { resetLinkPreviews } from './link-previews';
1013 import { resetQuickReactions } from './quick-reactions';
@@ -17,8 +20,9 @@ import {
1720 parseRecentHistoryMap,
1821 parseThreadListEntries,
1922 parseRecentHistoryState,
20- parseRoomList,
21- parseTimelineList,
23+ parseRoomSummary,
24+ identifyTimelineItem,
25+ parseTimelineItem,
2226 parseVerificationState
2327 } from './types';
2428 import type {
@@ -103,7 +107,28 @@ export function dismissBanner(id: number): void {
103107 banners.update((list) => list.filter((b) => b.id !== id));
104108 }
105109
110+const timelineCaches = new Map<string, ShareCache<TimelineItem>>();
111+const pinnedCaches = new Map<string, ShareCache<TimelineItem>>();
112+const threadCaches = new Map<string, ShareCache<TimelineItem>>();
113+const roomsCache = newShareCache<RoomSummary>();
114+
115+function parseCached(caches: Map<string, ShareCache<TimelineItem>>, key: string, raw: unknown): TimelineItem[] {
116+ let cache = caches.get(key);
117+ if (!cache) caches.set(key, (cache = newShareCache()));
118+ return parseShared(cache, raw, parseTimelineItem, identifyTimelineItem);
119+}
120+
121+function setKeyed<T extends unknown[]>(store: Writable<Record<string, T>>, key: string, value: T): void {
122+ const all = get(store);
123+ if (all[key] !== value) store.set({ ...all, [key]: value });
124+}
125+
106126 export function resetClientState(): void {
127+ timelineCaches.clear();
128+ pinnedCaches.clear();
129+ threadCaches.clear();
130+ roomsCache.byKey = new Map();
131+ roomsCache.list = [];
107132 mentionsSnapshot.set(null);
108133 rooms.set([]);
109134 timelines.set({});
@@ -168,12 +193,17 @@ function withArrivals(roomId: string, previous: TimelineItem[], items: TimelineI
168193
169194 function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
170195 if (!Array.isArray(payload['items'])) return;
171- const items = parseTimelineList(payload['items']);
172- timelines.update((all) => ({ ...all, [roomId]: withArrivals(roomId, all[roomId] ?? [], items) }));
196+ const items = parseCached(timelineCaches, roomId, payload['items']);
197+ setKeyed(timelines, roomId, withArrivals(roomId, get(timelines)[roomId] ?? [], items));
198+}
199+
200+function identifyRoom(raw: unknown): string | null {
201+ return isRecord(raw) && typeof raw['room_id'] === 'string' ? raw['room_id'] : null;
173202 }
174203
175204 function setRooms(raw: unknown): void {
176- rooms.set(parseRoomList(raw));
205+ const next = parseShared(roomsCache, raw, parseRoomSummary, identifyRoom);
206+ if (!sameList(get(rooms), next)) rooms.set(next);
177207 }
178208
179209 function handlePayload(text: string): void {
@@ -196,7 +226,7 @@ function handlePayload(text: string): void {
196226 timelines.update((all) => {
197227 const next = { ...all };
198228 for (const [roomId, list] of Object.entries(rawTimelines)) {
199- next[roomId] = withArrivals(roomId, all[roomId] ?? [], parseTimelineList(list));
229+ next[roomId] = withArrivals(roomId, all[roomId] ?? [], parseCached(timelineCaches, roomId, list));
200230 }
201231 return next;
202232 });
@@ -205,7 +235,8 @@ function handlePayload(text: string): void {
205235 const next: Record<string, TimelineItem[]> = {};
206236 for (const entry of data['threads']) {
207237 if (!isRecord(entry) || typeof entry['room_id'] !== 'string' || typeof entry['root_event_id'] !== 'string') continue;
208- next[threadKey(entry['room_id'], entry['root_event_id'])] = parseTimelineList(entry['items']);
238+ const key = threadKey(entry['room_id'], entry['root_event_id']);
239+ next[key] = parseCached(threadCaches, key, entry['items']);
209240 }
210241 threadTimelines.set(next);
211242 }
@@ -213,7 +244,7 @@ function handlePayload(text: string): void {
213244 const rawPinned = data['pinned'];
214245 if (isRecord(rawPinned)) {
215246 const next: Record<string, TimelineItem[]> = {};
216- for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseTimelineList(list);
247+ for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseCached(pinnedCaches, roomId, list);
217248 pinnedTimelines.set(next);
218249 }
219250 const snapshotEncryption = parseEncryptionState(data['encryption']);
@@ -251,11 +282,20 @@ function handlePayload(text: string): void {
251282 if (typeof roomId === 'string') applyTimelinePayload(roomId, data);
252283 return;
253284 }
285+ if (kind === 'timeline_closed') {
286+ const roomId = data['room_id'];
287+ if (typeof roomId !== 'string') return;
288+ timelineCaches.delete(roomId);
289+ timelines.update((all) => {
290+ const { [roomId]: _closed, ...rest } = all;
291+ return rest;
292+ });
293+ return;
294+ }
254295 if (kind === 'pinned') {
255296 const roomId = data['room_id'];
256297 if (typeof roomId === 'string' && Array.isArray(data['items'])) {
257- const items = parseTimelineList(data['items']);
258- pinnedTimelines.update((all) => ({ ...all, [roomId]: items }));
298+ setKeyed(pinnedTimelines, roomId, parseCached(pinnedCaches, roomId, data['items']));
259299 }
260300 return;
261301 }
@@ -278,9 +318,9 @@ function handlePayload(text: string): void {
278318 if (typeof roomId !== 'string' || typeof root !== 'string') return;
279319 const key = threadKey(roomId, root);
280320 if (kind === 'thread') {
281- const items = parseTimelineList(data['items']);
282- threadTimelines.update((all) => ({ ...all, [key]: items }));
321+ setKeyed(threadTimelines, key, parseCached(threadCaches, key, data['items']));
283322 } else {
323+ threadCaches.delete(key);
284324 threadTimelines.update((all) => {
285325 const { [key]: _closed, ...rest } = all;
286326 return rest;
@@ -291,6 +331,7 @@ function handlePayload(text: string): void {
291331 if (kind === 'pinned_closed') {
292332 const roomId = data['room_id'];
293333 if (typeof roomId === 'string') {
334+ pinnedCaches.delete(roomId);
294335 pinnedTimelineVersions.update((all) => ({ ...all, [roomId]: (all[roomId] ?? 0) + 1 }));
295336 pinnedTimelines.update((all) => {
296337 const { [roomId]: _closed, ...rest } = all;
Modified file: web/src/lib/threads.ts+52 additions−11 deletions
@@ -1,8 +1,9 @@
1-import { derived, get, writable } from 'svelte/store';
1+import { derived, get, readable, writable } from 'svelte/store';
22 import { closeThreadList, getAccountData, openThreadList, paginateThreadList, runCommand, setAccountData } from './api';
33 import { pushBanner, rooms, sessionUser, threadLists, timelines } from './store';
44 import { roomThreads } from './thread-index';
55 import type { ThreadEntry } from './thread-index';
6+import type { ThreadListEntry, ThreadUnread, TimelineItem } from './types';
67
78 const PINNED_TYPE = 'dev.malice.pinned_threads';
89
@@ -15,16 +16,56 @@ export interface OpenThread {
1516 export const openThread = writable<OpenThread | null>(null);
1617 export const pinnedThreads = writable<Record<string, string[]>>({});
1718
18-export const threadsByRoom = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
19- const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
20- const out: Record<string, ThreadEntry[]> = {};
21- const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
22- for (const roomId of new Set(ids)) {
23- const list = roomThreads(roomId, $timelines[roomId] ?? [], $pinned[roomId] ?? [], $lists[roomId]?.entries ?? [], unreads.get(roomId) ?? {});
24- if (list.length) out[roomId] = list;
25- }
26- return out;
27-});
19+const NO_TIMELINE: TimelineItem[] = [];
20+const NO_PINS: string[] = [];
21+const NO_ENTRIES: ThreadListEntry[] = [];
22+const NO_UNREADS: Record<string, ThreadUnread> = {};
23+
24+interface RoomThreadsCache {
25+ timeline: TimelineItem[];
26+ pins: string[];
27+ entries: ThreadListEntry[];
28+ unreads: Record<string, ThreadUnread>;
29+ list: ThreadEntry[];
30+}
31+
32+function createThreadsByRoom() {
33+ let cache = new Map<string, RoomThreadsCache>();
34+ let last: Record<string, ThreadEntry[]> = {};
35+ const computed = derived([timelines, pinnedThreads, threadLists, rooms], ([$timelines, $pinned, $lists, $rooms]) => {
36+ const unreads = new Map($rooms.map((room) => [room.room_id, room.thread_unreads]));
37+ const next = new Map<string, RoomThreadsCache>();
38+ const out: Record<string, ThreadEntry[]> = {};
39+ const ids = [...Object.keys($timelines), ...Object.keys($pinned), ...Object.keys($lists), ...unreads.keys()];
40+ for (const roomId of new Set(ids)) {
41+ const timeline = $timelines[roomId] ?? NO_TIMELINE;
42+ const pins = $pinned[roomId] ?? NO_PINS;
43+ const entries = $lists[roomId]?.entries ?? NO_ENTRIES;
44+ const roomUnreads = unreads.get(roomId) ?? NO_UNREADS;
45+ const hit = cache.get(roomId);
46+ const reuse = hit && hit.timeline === timeline && hit.pins === pins && hit.entries === entries && hit.unreads === roomUnreads;
47+ const entry = reuse ? hit : { timeline, pins, entries, unreads: roomUnreads, list: roomThreads(roomId, timeline, pins, entries, roomUnreads) };
48+ next.set(roomId, entry);
49+ if (entry.list.length) out[roomId] = entry.list;
50+ }
51+ const keys = Object.keys(out);
52+ const unchanged = keys.length === Object.keys(last).length && keys.every((roomId) => out[roomId] === last[roomId]);
53+ cache = next;
54+ if (unchanged) return last;
55+ last = out;
56+ return out;
57+ });
58+ return readable(last, (set) => {
59+ let sent: Record<string, ThreadEntry[]> | null = null;
60+ return computed.subscribe((value) => {
61+ if (value === sent) return;
62+ sent = value;
63+ set(value);
64+ });
65+ });
66+}
67+
68+export const threadsByRoom = createThreadsByRoom();
2869
2970 const THREAD_PAGE = 20;
3071 const openLists = new Set<string>();
Added file: web/src/worker/coalesce.ts+52 additions
@@ -0,0 +1,52 @@
1+const STRING = '"(?:[^"\\\\]|\\\\.)*"';
2+const REPLACEMENT = new RegExp(`^\\{"type":"(rooms|timeline|pinned|thread|thread_list)"(?:,"room_id":(${STRING}))?(?:,"root_event_id":(${STRING}))?`);
3+
4+/** Serialised key for events that replace the full state of one room, thread or list, or null for anything else. */
5+export function replacementKey(event: string): string | null {
6+ const match = REPLACEMENT.exec(event);
7+ if (!match) return null;
8+ const [, type, room, root] = match;
9+ if (type === 'rooms') return type;
10+ if (room === undefined) return null;
11+ if (type === 'thread') return root === undefined ? null : `${type}:${room}:${root}`;
12+ return `${type}:${room}`;
13+}
14+
15+/**
16+ * Buffers full-state events so a burst delivers only the latest per key. Anything else flushes
17+ * the buffer first, so deltas and lifecycle events keep their order relative to the buffered ones.
18+ */
19+export class EventCoalescer {
20+ private pending = new Map<string, { account: string; event: string }>();
21+ private scheduled = false;
22+
23+ constructor(
24+ private deliver: (account: string, event: string) => void,
25+ private schedule: (flush: () => void) => void = (flush) => void setTimeout(flush, 0)
26+ ) {}
27+
28+ push(account: string, event: string): void {
29+ const key = replacementKey(event);
30+ if (key === null) {
31+ this.flush();
32+ this.deliver(account, event);
33+ return;
34+ }
35+ const slot = `${account}\n${key}`;
36+ this.pending.delete(slot);
37+ this.pending.set(slot, { account, event });
38+ if (this.scheduled) return;
39+ this.scheduled = true;
40+ this.schedule(() => {
41+ this.scheduled = false;
42+ this.flush();
43+ });
44+ }
45+
46+ flush(): void {
47+ if (this.pending.size === 0) return;
48+ const batch = [...this.pending.values()];
49+ this.pending.clear();
50+ for (const { account, event } of batch) this.deliver(account, event);
51+ }
52+}
Modified file: web/src/worker/worker.ts+20 additions−9 deletions
@@ -1,5 +1,6 @@
11 import init, { Host } from './pkg/malice_worker.js';
22 import { isRecord } from '../lib/guards';
3+import { EventCoalescer } from './coalesce';
34 import {
45 createAccount,
56 deleteAccount,
@@ -48,6 +49,8 @@ function broadcastEvent(account: string, event: string): void {
4849 for (const port of subscriptions.get(account) ?? []) postTo(port, { kind: 'event', account, event });
4950 }
5051
52+const coalescer = new EventCoalescer(broadcastEvent);
53+
5154 function toWireError(error: unknown): WireError {
5255 if (isRecord(error) && typeof error['kind'] === 'string') {
5356 return { kind: error['kind'], message: typeof error['message'] === 'string' ? error['message'] : 'unknown error' };
@@ -76,21 +79,24 @@ function onSessionEnded(account: string, retain: unknown): void {
7679 } else if (identity) {
7780 softAccounts.set(account, identity);
7881 }
82+ coalescer.flush();
7983 broadcastEvent(account, JSON.stringify({ type: 'session_ended', retain }));
8084 }
8185
86+const SESSION_ENDED_PREFIX = '{"type":"session_ended"';
87+
8288 function onHostEvent(account: string, eventJson: string): void {
83- let parsed: unknown;
84- try {
85- parsed = JSON.parse(eventJson);
86- } catch {
87- return;
88- }
89- if (isRecord(parsed) && parsed['type'] === 'session_ended') {
90- onSessionEnded(account, parsed['retain']);
89+ if (eventJson.startsWith(SESSION_ENDED_PREFIX)) {
90+ let retain: unknown;
91+ try {
92+ retain = (JSON.parse(eventJson) as { retain?: unknown }).retain;
93+ } catch {
94+ return;
95+ }
96+ onSessionEnded(account, retain);
9197 return;
9298 }
93- broadcastEvent(account, eventJson);
99+ coalescer.push(account, eventJson);
94100 }
95101
96102 function onHostFatal(message: string): void {
@@ -183,6 +189,7 @@ async function handleCall(account: string, request: unknown, bytes?: ArrayBuffer
183189 }
184190
185191 function handleSubscribe(port: PortLike, account: string): void {
192+ coalescer.flush();
186193 let set = subscriptions.get(account);
187194 if (!set) {
188195 set = new Set();
@@ -196,10 +203,12 @@ function handleSubscribe(port: PortLike, account: string): void {
196203 }
197204
198205 function handleUnsubscribe(port: PortLike, account: string): void {
206+ coalescer.flush();
199207 subscriptions.get(account)?.delete(port);
200208 }
201209
202210 function dropPort(port: PortLike): void {
211+ coalescer.flush();
203212 ports.delete(port);
204213 for (const set of subscriptions.values()) set.delete(port);
205214 }
@@ -210,9 +219,11 @@ async function dispatch(port: PortLike, data: unknown): Promise<void> {
210219 const kind = data['kind'];
211220 const id = data['id'];
212221 const reply = (ok: unknown) => {
222+ coalescer.flush();
213223 if (typeof id === 'number') postTo(port, { kind: 'reply', id, ok });
214224 };
215225 const fail = (error: unknown) => {
226+ coalescer.flush();
216227 if (typeof id === 'number') postTo(port, { kind: 'reply', id, error: toWireError(error) });
217228 };
218229
Added file: web/test/coalesce.test.ts+71 additions
@@ -0,0 +1,71 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { EventCoalescer, replacementKey } from '../src/worker/coalesce.ts';
4+
5+function setup() {
6+ const delivered: string[] = [];
7+ const queue: Array<() => void> = [];
8+ const coalescer = new EventCoalescer(
9+ (account, event) => delivered.push(`${account}|${event}`),
10+ (flush) => queue.push(flush)
11+ );
12+ return { delivered, coalescer, run: () => queue.splice(0).forEach((flush) => flush()), queue };
13+}
14+
15+const timeline = (room: string, n: number) => `{"type":"timeline","room_id":"${room}","items":[${n}]}`;
16+
17+test('keys cover full-state events only', () => {
18+ assert.equal(replacementKey(timeline('!a', 1)), 'timeline:"!a"');
19+ assert.equal(replacementKey('{"type":"rooms","rooms":[]}'), 'rooms');
20+ assert.equal(replacementKey('{"type":"thread","room_id":"!a","root_event_id":"$r","items":[]}'), 'thread:"!a":"$r"');
21+ assert.equal(replacementKey('{"type":"thread_list","room_id":"!a","entries":[]}'), 'thread_list:"!a"');
22+ assert.equal(replacementKey('{"type":"pinned","room_id":"!a","items":[]}'), 'pinned:"!a"');
23+ assert.equal(replacementKey('{"type":"thread_closed","room_id":"!a","root_event_id":"$r"}'), null);
24+ assert.equal(replacementKey('{"type":"thread_list_history","room_id":"!a","state":"Idle"}'), null);
25+ assert.equal(replacementKey('{"type":"timeline_closed","room_id":"!a"}'), null);
26+ assert.equal(replacementKey('{"type":"snapshot"}'), null);
27+ assert.equal(replacementKey('{"type":"connection","state":"online"}'), null);
28+});
29+
30+test('a burst for one key delivers only the latest, once, on the scheduled flush', () => {
31+ const { delivered, coalescer, run, queue } = setup();
32+ coalescer.push('acc', timeline('!a', 1));
33+ coalescer.push('acc', timeline('!a', 2));
34+ coalescer.push('acc', timeline('!a', 3));
35+ assert.equal(queue.length, 1);
36+ assert.deepEqual(delivered, []);
37+ run();
38+ assert.deepEqual(delivered, [`acc|${timeline('!a', 3)}`]);
39+});
40+
41+test('other events flush pending ones first and keep their order', () => {
42+ const { delivered, coalescer, run } = setup();
43+ coalescer.push('acc', timeline('!a', 1));
44+ coalescer.push('acc', timeline('!b', 1));
45+ coalescer.push('acc', '{"type":"timeline_closed","room_id":"!a"}');
46+ coalescer.push('acc', timeline('!a', 2));
47+ run();
48+ assert.deepEqual(delivered, [
49+ `acc|${timeline('!a', 1)}`,
50+ `acc|${timeline('!b', 1)}`,
51+ 'acc|{"type":"timeline_closed","room_id":"!a"}',
52+ `acc|${timeline('!a', 2)}`
53+ ]);
54+});
55+
56+test('different keys and accounts are kept apart', () => {
57+ const { delivered, coalescer, run } = setup();
58+ coalescer.push('x', timeline('!a', 1));
59+ coalescer.push('y', timeline('!a', 2));
60+ coalescer.push('x', timeline('!a', 3));
61+ run();
62+ assert.deepEqual(delivered, [`y|${timeline('!a', 2)}`, `x|${timeline('!a', 3)}`]);
63+});
64+
65+test('a flush with nothing pending after an explicit flush delivers nothing twice', () => {
66+ const { delivered, coalescer, run } = setup();
67+ coalescer.push('acc', timeline('!a', 1));
68+ coalescer.flush();
69+ run();
70+ assert.equal(delivered.length, 1);
71+});
Added file: web/test/sharing.test.ts+80 additions
@@ -0,0 +1,80 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { newShareCache, parseShared } from '../src/lib/sharing.ts';
4+import { observeArrivals } from '../src/lib/arrivals.ts';
5+import type { TimelineEvent, TimelineItem } from '../src/lib/types.ts';
6+
7+interface Row {
8+ id: string;
9+ count: number;
10+}
11+
12+function parse(entry: unknown): Row | null {
13+ const raw = entry as Record<string, unknown>;
14+ return typeof raw['id'] === 'string' ? { id: raw['id'], count: Number(raw['count']) } : null;
15+}
16+
17+test('unchanged entries keep their object and unchanged lists keep their array', () => {
18+ const cache = newShareCache<Row>();
19+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);
20+ const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);
21+ assert.equal(second, first);
22+});
23+
24+test('a changed entry gets a new object while its neighbours keep theirs', () => {
25+ const cache = newShareCache<Row>();
26+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);
27+ const second = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 2 }], parse);
28+ assert.notEqual(second, first);
29+ assert.equal(second[0], first[0]);
30+ assert.notEqual(second[1], first[1]);
31+ assert.equal(second[1].count, 2);
32+});
33+
34+test('appended and removed entries change the array', () => {
35+ const cache = newShareCache<Row>();
36+ const first = parseShared(cache, [{ id: 'a', count: 1 }], parse);
37+ const grown = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse);
38+ assert.notEqual(grown, first);
39+ assert.equal(grown[0], first[0]);
40+ const shrunk = parseShared(cache, [{ id: 'b', count: 1 }], parse);
41+ assert.equal(shrunk[0], grown[1]);
42+});
43+
44+test('unparsable entries are skipped and non-arrays yield an empty list', () => {
45+ const cache = newShareCache<Row>();
46+ assert.equal(parseShared(cache, [{ id: 'a', count: 1 }, { nope: true }], parse).length, 1);
47+ assert.deepEqual(parseShared(cache, 'x', parse), []);
48+});
49+
50+function event(id: string, timestamp_ms: number): TimelineItem {
51+ return { kind: 'event', event: { item_id: id, timestamp_ms, own: false } as TimelineEvent };
52+}
53+
54+test('arrivals keeps array and lagged item identity across identical updates', () => {
55+ const MINUTE = 60 * 1000;
56+ const known = event('a', 90 * MINUTE);
57+ const late = event('late', 80 * MINUTE);
58+ const first = observeArrivals([known], [known, late], 0, 100 * MINUTE);
59+ assert.ok(first);
60+ const second = observeArrivals(first, [known, late], 0, 101 * MINUTE);
61+ assert.equal(second, first);
62+});
63+
64+test('identified entries are matched by identity and evicted when absent', () => {
65+ const cache = newShareCache<Row>();
66+ const identify = (entry: unknown) => (entry as Row).id;
67+ const first = parseShared(cache, [{ id: 'a', count: 1 }, { id: 'b', count: 1 }], parse, identify);
68+ const second = parseShared(cache, [{ id: 'b', count: 1 }, { id: 'c', count: 1 }], parse, identify);
69+ assert.equal(second[0], first[1]);
70+ assert.deepEqual([...cache.byKey.keys()], ['b', 'c']);
71+});
72+
73+test('a changed identified entry is reparsed under the same key', () => {
74+ const cache = newShareCache<Row>();
75+ const identify = (entry: unknown) => (entry as Row).id;
76+ const first = parseShared(cache, [{ id: 'a', count: 1 }], parse, identify);
77+ const second = parseShared(cache, [{ id: 'a', count: 2 }], parse, identify);
78+ assert.notEqual(second[0], first[0]);
79+ assert.equal(second[0].count, 2);
80+});