fix(bookmarks): harden sort/group after review
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s

Address findings from 2 review agents on the bookmark sort/group feature:

- Flash on open: the persisted-sort atom now uses getOnInit so the saved
  sort applies on the first render instead of briefly showing Newest and
  reordering after mount.

- Stale collapse state: prune collapsed roomIds that no longer have any
  bookmark, so a room re-saved later doesn't reappear pre-collapsed and
  the Set can't grow unbounded across a session.

- Corrupt persisted value: validate the stored sort with a new
  isBookmarkSort type guard, normalizing anything unexpected to Newest so
  exactly one sort button is always active.

- a11y: room group headers now expose an explicit aria-label
  ("<room>, N saved messages") instead of announcing the avatar alt and
  the visible name twice with a bare count, plus aria-controls linking the
  header to its collapsible content region.

- Layout: move the sort control to its own toolbar row so the three
  buttons don't crowd the count text in the narrow (266px) panel.

- Memoize filtered/sortedItems/groups for consistency with renderItem.

Adds unit tests for isBookmarkSort, group-order tie-break, and
groupBookmarksByRoom immutability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:09:42 -04:00
co-authored by Claude Opus 4.8
parent cb3cd30ab5
commit 39e75f4eea
3 changed files with 114 additions and 20 deletions
+32 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sortBookmarks, groupBookmarksByRoom } from './bookmarks';
import { sortBookmarks, groupBookmarksByRoom, isBookmarkSort } from './bookmarks';
import { Bookmark } from '../hooks/useBookmarks';
const bk = (eventId: string, roomId: string, savedAt: number, roomName = roomId): Bookmark => ({
@@ -99,6 +99,37 @@ test('groupBookmarksByRoom carries the stored room name', () => {
assert.equal(groups[0].roomName, 'General');
});
test('groupBookmarksByRoom orders equal-newest groups deterministically by roomId', () => {
// Both rooms' newest save is 200 → tie broken by roomId ascending.
const input = [bk('a', 'rB', 200), bk('b', 'rA', 200), bk('c', 'rA', 100)];
const groups = groupBookmarksByRoom(input);
assert.deepEqual(
groups.map((g) => g.roomId),
['rA', 'rB'],
);
});
test('groupBookmarksByRoom does not mutate its input', () => {
const input = [bk('a', 'r1', 100), bk('b', 'r2', 300), bk('c', 'r1', 200)];
const before = input.map((b) => b.eventId);
groupBookmarksByRoom(input);
assert.deepEqual(
input.map((b) => b.eventId),
before,
);
});
test('groupBookmarksByRoom returns empty for empty input', () => {
assert.deepEqual(groupBookmarksByRoom([]), []);
});
test('isBookmarkSort accepts valid values and rejects everything else', () => {
assert.equal(isBookmarkSort('newest'), true);
assert.equal(isBookmarkSort('oldest'), true);
assert.equal(isBookmarkSort('room'), true);
assert.equal(isBookmarkSort('bogus'), false);
assert.equal(isBookmarkSort(''), false);
assert.equal(isBookmarkSort(undefined), false);
assert.equal(isBookmarkSort(null), false);
assert.equal(isBookmarkSort(3), false);
});
+7
View File
@@ -2,6 +2,13 @@ import { Bookmark } from '../hooks/useBookmarks';
export type BookmarkSort = 'newest' | 'oldest' | 'room';
const BOOKMARK_SORTS: readonly BookmarkSort[] = ['newest', 'oldest', 'room'];
/** Type guard for persisted/untrusted sort values (localStorage can hold stale/corrupt data). */
export function isBookmarkSort(value: unknown): value is BookmarkSort {
return typeof value === 'string' && (BOOKMARK_SORTS as readonly string[]).includes(value);
}
export type BookmarkRoomGroup = {
roomId: string;
roomName: string;