v3: fix the frontend's findings, and the backend pieces they needed — each by a rule the code now keeps everywhere. Votes and likes go through one useOptimisticToggles in WSProvider: clicks on one item before any ack share one baseline, which a failure or the timeout restores (a second click used to cancel the first one's rollback), only the last ack's count is applied, a votes_update or comment_likes_update waits while this tab has toggles in flight, and a welcome reconciles them against the server's sets (a vote cast offline left its count one too high). lastLikeEvent, which nothing read, is gone. Follows are refetched when the new follows_changed message, sent to the follower's sockets on every follow write, says another tab changed them; a failed load keeps the Follow buttons disabled and is retried 10 s later instead of leaving them disabled for good (success: false) or enabled over empty sets (network error); follow and unfollow resolve to whether they worked, and the button says when they didn't. CategoriesProvider follows the same rule, so a network error no longer turns every category URL into a 404. Every list is kept by one rule: useDumpListSync and usePlaylistListSync never add an item, and usePositionAwareSync remembers the place of any item that leaves a list, whatever took it out, and puts it back when its next event shows it again; with dump_created now setting lastDumpEvent, a dump going private and back returns to its place on another user's profile, which never happened before, and an updated playlist missing from a list is no longer prepended. useFeedCache keys every cache by viewer (the profile pages restored the owner's private dumps to whoever went back) and reads it again when its key changes (another profile in the same page showed the previous one's list). Every response of a list's first page or next pages goes through expectOk (Index, UserPlaylists, useUserDumpFeed and Notifications read body.data unchecked).
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 55s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Smaller fixes: the redirects from / and /<slug> keep the navigation state, so the index hides the dump DumpEdit just deleted; Index's mainFetchDone ref, which blocked nothing, is gone; DumpEdit refuses someone else's dump up front, as the API does; the Dump page takes a handed-over dump once and clears it from the history entry, so a reload fetches; a newly upvoted dump is fetched with the viewer's token; the notifications page sends read-all once per notification; the profile's followed and invitees tabs finish loading when left mid-fetch; UserListPopover no longer closes when its own list scrolls; the vote and like counts open their lists for visitors too (a disabled button swallowed the click; they are aria-disabled now); the removal of a playlist's dump forgets it (an edit brought it back), a failed removal puts it back where it was, an undo waits for the removal in flight, and a refused undo says so; PlaylistCreateForm tells whether the dump made it into the new playlist; the cover preview's object URL is revoked; the muted icon has its cross; the bell no longer stays rung after two notifications within 700 ms; a stale invite check can't overwrite a newer one; a stored theme or color scheme is checked; the default feed tab is one store every reader follows, across tabs; the file fields enforce the size they state, derived from DUMP_MAX_FILE_SIZE_BYTES; formatBytes, friendlyFetchError and ImagePicker are translated (Intl names the units), and ImagePicker ignores keys while uploading; visited dumps and playlists keep a week of visits and are parsed once; a chime is claimed under a Web Lock, so two tabs can't both play it; a date in the future reads as "now". Dead code goes: PageShell's centerSlot, overLimit, the exports of isSafeHex and hueFromString, the comment tree's token prop and ChatContext's isChatOpen and closeChat. The French catalogue has the 11 new messages. 214 backend and 632 frontend tests pass; tsc, deno lint, ESLint and doc:lint are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 20:08:00 +00:00
parent a36a4cac6b
commit 80f3292c8f
84 changed files with 2451 additions and 1964 deletions

View File

@@ -10,29 +10,20 @@ import type { Dump } from "../../../shared/dump.ts";
import { useWS } from "../ws/useWS.ts";
/**
* Passed to {@link useDumpListSync} by a profile's list of dumps. The feeds
* pass none.
* Passed to {@link useDumpListSync} by a profile's own list of dumps. The
* feeds pass none.
*/
export interface DumpListSyncOptions {
/** Keeps an edited dump that is now private, for its owner's own list. */
isOwner?: boolean;
/**
* Prepends an edited dump missing from the list when `ownerId` posted it.
* Without it, a missing dump is never added.
*/
ownerId?: string;
/**
* Never adds a missing dump, whatever `ownerId`. A list that restores
* dumps at their old position, with `usePositionAwareSync`, sets it.
*/
skipReinsert?: boolean;
}
/**
* Applies the socket's dump and comment events to the list that `setDumps`
* updates. A deleted dump is removed, an edited one is replaced in place, and
* a new or deleted comment moves its dump's `commentCount`. It doesn't add new
* dumps: the feeds merge `WSContext`'s `recentDumps` themselves.
* a new or deleted comment moves its dump's `commentCount`. It never adds a
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
* profiles put back a dump that returns with `usePositionAwareSync`.
*/
export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
@@ -57,25 +48,18 @@ export function useDumpListSync(
useEffect(() => {
if (!lastDumpEvent) return;
const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {};
const isOwner = optionsRef.current?.isOwner;
const dump = lastDumpEvent;
setDumpsRef.current((prev) => {
const idx = prev.findIndex((d) => d.id === dump.id);
if (idx !== -1) {
if (dump.isPrivate && !isOwner) {
return prev.filter((d) => d.id !== dump.id);
}
const next = [...prev];
next[idx] = dump;
return next;
if (idx === -1) return prev;
if (dump.isPrivate && !isOwner) {
return prev.filter((d) => d.id !== dump.id);
}
if (skipReinsert || !ownerId) return prev;
if (dump.userId !== ownerId) return prev;
if (dump.isPrivate && !isOwner) return prev;
return [dump, ...prev];
const next = [...prev];
next[idx] = dump;
return next;
});
}, [lastDumpEvent]);

View File

@@ -15,7 +15,7 @@ import {
} from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
@@ -163,9 +163,8 @@ export function useUserDumpFeed(
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
.then(({ items, hasMore }) => {
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"

View File

@@ -4,7 +4,7 @@
* @module
*/
import { useCallback, useState } from "react";
import { useSyncExternalStore } from "react";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
@@ -13,28 +13,53 @@ import {
const STORAGE_KEY = "default-feed-tab";
/** The components reading the tab, told when this tab changes it. */
const listeners = new Set<() => void>();
/** The choice made while storage is blocked, which lasts until a reload. */
let unstored: FeedTab | null = null;
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
function readStored(): FeedTab {
const stored = localStorage.getItem(STORAGE_KEY);
let stored: string | null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch {
stored = unstored;
}
return stored && (FEED_TABS as readonly string[]).includes(stored)
? stored as FeedTab
: DEFAULT_FEED_TAB;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
// Another browser tab's choice arrives as a `storage` event.
globalThis.addEventListener("storage", listener);
return () => {
listeners.delete(listener);
globalThis.removeEventListener("storage", listener);
};
}
/** Stores `tab`, and tells every component that reads it. */
function setDefaultFeedTab(tab: FeedTab): void {
try {
localStorage.setItem(STORAGE_KEY, tab);
} catch {
unstored = tab;
}
for (const listener of listeners) listener();
}
/**
* Returns the feed tab the index opens on, and a setter that stores a new
* choice. The profile's settings set it. The index's redirect, its tab bar and
* `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is
* "followed" and nobody is signed in. Each call reads the stored tab once, so
* a change shows in other components only once they remount.
* "followed" and nobody is signed in. A new choice reaches every reader at
* once, in this browser tab and the others.
*/
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
const [tab, setTabState] = useState<FeedTab>(readStored);
const setTab = useCallback((t: FeedTab) => {
localStorage.setItem(STORAGE_KEY, t);
setTabState(t);
}, []);
return [tab, setTab];
const tab = useSyncExternalStore(subscribe, readStored);
return [tab, setDefaultFeedTab];
}

View File

@@ -7,6 +7,7 @@
import { useCallback, useState } from "react";
import { useNavigationType } from "react-router";
import { useAuth } from "../auth/useAuth.ts";
/** Past this age, a saved feed is refetched rather than restored. */
const TTL = 10 * 60 * 1000; // 10 minutes
@@ -30,7 +31,7 @@ export interface FeedCacheEntry<T> {
export interface UseFeedCacheResult<T> {
/**
* The saved feed, when the page was reached by going back or forward and the
* feed was saved less than 10 minutes ago. It doesn't change after mount.
* feed was saved less than 10 minutes ago. It changes only with the key.
*/
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
/** Usually called through `useScrollSave`, as the page is left. */
@@ -42,38 +43,56 @@ export interface UseFeedCacheResult<T> {
) => void;
}
/** Returns the entry saved under `key`, or null if none is fresh enough. */
function readEntry<T>(
key: string,
hydrateItem: (raw: T) => T,
): Omit<FeedCacheEntry<T>, "savedAt"> | null {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
if (Date.now() - entry.savedAt > TTL) return null;
return { ...entry, items: entry.items.map(hydrateItem) };
} catch {
return null;
}
}
/**
* Returns the feed saved under `key` and a way to save it. The feed pages and
* `useUserDumpFeed` use it. `key` must tell apart every list whose items
* differ, the viewer included when they see private items. `hydrateItem`
* restores what JSON lost, such as dates. A full or blocked storage is
* ignored.
* differ; the viewer is added to it here, since they see their own private
* items. `hydrateItem` restores what JSON lost, such as dates. A full or
* blocked storage is ignored.
*/
export function useFeedCache<T>(
key: string,
hydrateItem: (raw: T) => T,
): UseFeedCacheResult<T> {
const navType = useNavigationType();
const { user } = useAuth();
const fullKey = `${key}:${user?.id ?? "guest"}`;
// Read once: a new navigation to the page must not pick up what it saves.
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
if (navType !== "POP") return null;
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
if (Date.now() - entry.savedAt > TTL) return null;
return { ...entry, items: entry.items.map(hydrateItem) };
} catch {
return null;
}
});
// Read per key, not on every render: a page that saves as it is left must
// not pick up its own save. A new key (another profile, in the same page)
// is read afresh.
const [read, setRead] = useState(() => ({
key: fullKey,
cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
}));
if (read.key !== fullKey) {
setRead({
key: fullKey,
cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
});
}
const saveState = useCallback(
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
try {
sessionStorage.setItem(
key,
fullKey,
JSON.stringify({
items,
page,
@@ -86,8 +105,11 @@ export function useFeedCache<T>(
// Full or blocked storage: the page just refetches next time.
}
},
[key],
[fullKey],
);
return { cached, saveState };
return {
cached: read.key === fullKey ? read.cached : null,
saveState,
};
}

View File

@@ -8,45 +8,37 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Remembers the index of an item that `lastEvent` removes from `items`, and
* puts the item back there when a later event makes it visible again. The
* profile pages use it with `WSContext`'s latest dump or playlist edit.
* `items` is the loaded list, or `[]` before it loads, and `setItems` the
* setter also passed to `useDumpListSync` or `usePlaylistListSync`.
* `isRemoving` tells an event that takes the item out, and `shouldReinsert`
* one that brings it back.
*
* It must be called before the sync hook, so that its effects run first: it
* saves the index before the sync hook removes the item. The sync hook must
* get `skipReinsert`, or it would add the item a second time.
* Remembers the index of every item that leaves `items`, whatever took it
* out, and puts it back there when `lastEvent` shows it again and
* `shouldReinsert` accepts it. The profile pages use it with `WSContext`'s
* latest dump or playlist event, beside `useDumpListSync` or
* `usePlaylistListSync`, which never re-add an item themselves. `items` is the
* loaded list, or `[]` before it loads, and `setItems` its setter.
*/
export function usePositionAwareSync<T extends { id: string }>(
items: T[],
setItems: (fn: (prev: T[]) => T[]) => void,
lastEvent: T | null,
isRemoving: (item: T) => boolean,
shouldReinsert: (item: T) => boolean,
): void {
const itemsRef = useRef<T[]>(items);
const setItemsRef = useRef(setItems);
const isRemovingRef = useRef(isRemoving);
const shouldReinsertRef = useRef(shouldReinsert);
useLayoutEffect(() => {
itemsRef.current = items;
setItemsRef.current = setItems;
isRemovingRef.current = isRemoving;
shouldReinsertRef.current = shouldReinsert;
});
const previousRef = useRef<T[]>(items);
const removedPositionsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
if (idx !== -1) {
removedPositionsRef.current.set(lastEvent.id, idx);
}
}, [lastEvent]);
const present = new Set(items.map((item) => item.id));
previousRef.current.forEach((item, idx) => {
if (!present.has(item.id)) removedPositionsRef.current.set(item.id, idx);
});
for (const id of present) removedPositionsRef.current.delete(id);
previousRef.current = items;
}, [items]);
useEffect(() => {
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;

View File

@@ -24,19 +24,14 @@ export interface PlaylistListSyncOptions {
* and go.
*/
noNewEntries?: boolean;
/**
* Leaves out a playlist that comes back into view, for a caller that puts it
* back where it was with `usePositionAwareSync`.
*/
skipReinsert?: boolean;
}
/**
* Applies the socket's playlist events to the list set through
* `setPlaylists`. A created playlist goes first, an updated one changes in
* place or leaves when it turns private (unless `isOwner`), a deleted one
* leaves, and a
* playlist's `dumpCount` follows its dumps.
* leaves, and a playlist's `dumpCount` follows its dumps. An update never
* adds a playlist the list doesn't hold.
*/
export function usePlaylistListSync(
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
@@ -67,15 +62,9 @@ export function usePlaylistListSync(
if (ev.type === "updated" && ev.playlist) {
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
if (idx === -1) {
// Left the list when it turned private, and may be back. A followed
// list can't tell whether the user still follows it.
if (noNewEntries) return prev;
if (ownerId && ev.playlist.userId !== ownerId) return prev;
if (!ev.playlist.isPublic && !isOwner) return prev;
if (optionsRef.current?.skipReinsert) return prev;
return [ev.playlist, ...prev];
}
// One that left when it turned private is put back by
// `usePositionAwareSync`, where it was.
if (idx === -1) return prev;
if (!ev.playlist.isPublic && !isOwner) {
return prev.filter((p) => p.id !== ev.playlist!.id);
}