v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,8 @@ import type { Dump } from "../../../shared/dump.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link useDumpListSync} by a profile's own list of dumps. The
|
||||
* feeds pass none.
|
||||
* Passed to {@link useDumpListSync} by a profile's own list of dumps, and by
|
||||
* `usePlaylist` for the playlist's owner. The feeds pass none.
|
||||
*/
|
||||
export interface DumpListSyncOptions {
|
||||
/** Keeps an edited dump that is now private, for its owner's own list. */
|
||||
@@ -23,7 +23,8 @@ export interface DumpListSyncOptions {
|
||||
* 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 never adds a
|
||||
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
|
||||
* profiles put back a dump that returns with `usePositionAwareSync`.
|
||||
* profiles and `usePlaylist` put back a dump that returns with
|
||||
* `usePositionAwareSync`.
|
||||
*/
|
||||
export function useDumpListSync(
|
||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||
|
||||
@@ -17,25 +17,27 @@ import { fadeClassName, useFading } from "../feed/useFading.ts";
|
||||
|
||||
/** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */
|
||||
export interface UseUpvotedListOptions {
|
||||
/** Whose upvotes the list holds, null while their profile loads. */
|
||||
/**
|
||||
* The list holds this user's upvotes. It is null while their profile loads.
|
||||
*/
|
||||
profileUserId: string | null;
|
||||
/** Does nothing until the list is loaded. */
|
||||
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
||||
/**
|
||||
* Dumps already at hand, which a vote of the signed-in user on their own
|
||||
* list puts first at once, before the socket's vote event. The profile
|
||||
* passes its first page of the user's dumps.
|
||||
* It holds dumps already at hand, which a vote of the signed-in user on
|
||||
* their own list puts first at once, before the socket's vote event. The
|
||||
* profile passes its first page of the user's dumps.
|
||||
*/
|
||||
source?: Dump[];
|
||||
}
|
||||
|
||||
/** Returned by {@link useUpvotedList}. */
|
||||
export interface UseUpvotedListResult {
|
||||
/** For `DumpCardList`'s `cardClassName`. */
|
||||
/** It goes to `DumpCardList`'s `cardClassName`. */
|
||||
cardClassName: (dump: Dump) => string | undefined;
|
||||
/**
|
||||
* Whether the vote on dump `id` was withdrawn since the list loaded. The
|
||||
* profile's `usePositionAwareSync` keeps such a dump from coming back.
|
||||
* Checks whether the vote on dump `id` was withdrawn since the list loaded.
|
||||
* The profile's `usePositionAwareSync` keeps such a dump from coming back.
|
||||
*/
|
||||
isWithdrawn: (id: string) => boolean;
|
||||
}
|
||||
@@ -87,9 +89,10 @@ export function useUpvotedList(
|
||||
if (!isOwn) return;
|
||||
const prev = prevMyVotesRef.current;
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
// Only a baseline: the list as loaded stands. myVotes may still be the
|
||||
// empty set from before the socket's welcome (or the socket may never
|
||||
// connect), and taking it as the truth here would hide every dump.
|
||||
// This is only a baseline, and the list as loaded stands. myVotes may
|
||||
// still be the empty set from before the socket's welcome (or the socket
|
||||
// may never connect), and taking it as the truth here would hide every
|
||||
// dump.
|
||||
if (prev === null) return;
|
||||
for (const id of prev) if (!myVotes.has(id)) withdraw(id);
|
||||
const added = new Set([...myVotes].filter((id) => !prev.has(id)));
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
|
||||
import { type RefCallback, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
|
||||
import { fetchFirstPage } from "../../utils/feed/pagedList.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
mapItems,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
readPage,
|
||||
restoredList,
|
||||
} from "../../utils/feed/pagedList.ts";
|
||||
import type { LoadState } from "../../utils/shared/loadState.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
@@ -21,6 +29,7 @@ import {
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
|
||||
import { useLoadMore } from "../feed/usePagedList.ts";
|
||||
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
|
||||
|
||||
/**
|
||||
@@ -28,24 +37,20 @@ import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
|
||||
* when the user doesn't exist, and holds an empty list when only the dumps
|
||||
* failed to load.
|
||||
*/
|
||||
export type UserDumpFeedState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
profileUser: PublicUser;
|
||||
items: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
export type UserDumpFeedState = LoadState<{
|
||||
profileUser: PublicUser;
|
||||
dumps: PagedList<Dump>;
|
||||
}>;
|
||||
|
||||
/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */
|
||||
export interface UseUserDumpFeedResult {
|
||||
state: UserDumpFeedState;
|
||||
/** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */
|
||||
/**
|
||||
* It does nothing until loaded. It goes to `useDumpListSync` and
|
||||
* `useUpvotedList`.
|
||||
*/
|
||||
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
||||
/** From `useInfiniteScroll`, for an element after the last card. */
|
||||
/** It comes from `useInfiniteScroll`, for an element after the last card. */
|
||||
sentinelRef: RefCallback<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ export function useUserDumpFeed(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): UseUserDumpFeedResult {
|
||||
const { token } = useAuth();
|
||||
const { authFetch } = useAuth();
|
||||
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
|
||||
|
||||
const [state, setState] = useState<UserDumpFeedState>({ status: "loading" });
|
||||
@@ -72,9 +77,23 @@ export function useUserDumpFeed(
|
||||
setState({ status: "loading" });
|
||||
}
|
||||
|
||||
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
|
||||
}, []);
|
||||
const setDumps = useCallback(
|
||||
(fn: (list: PagedList<Dump>) => PagedList<Dump>) =>
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
|
||||
[],
|
||||
);
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) => setDumps(mapItems(fn)),
|
||||
[setDumps],
|
||||
);
|
||||
|
||||
const url = `${API_URL}/api/users/${username}/${endpoint}`;
|
||||
const fetchPage = useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserializeDump)),
|
||||
[authFetch, url],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
@@ -82,72 +101,31 @@ export function useUserDumpFeed(
|
||||
const { signal } = controller;
|
||||
// Back on the page, the list comes from the cache and only the user is
|
||||
// fetched again.
|
||||
const list = cached ?? fetchFirstPage(
|
||||
`${API_URL}/api/users/${username}/${endpoint}`,
|
||||
deserializeDump,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
|
||||
);
|
||||
const list = cached
|
||||
? restoredList(cached)
|
||||
: authFetch(pageUrl(url, 1), { signal })
|
||||
.then((res) => readFirstPage(res, deserializeDump));
|
||||
Promise.all([fetchPublicUser(username, signal), list])
|
||||
.then(([profileUser, { items, hasMore, page }]) =>
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser,
|
||||
items,
|
||||
hasMore,
|
||||
page,
|
||||
loadingMore: false,
|
||||
})
|
||||
.then(([profileUser, dumps]) =>
|
||||
setState({ status: "loaded", profileUser, dumps })
|
||||
)
|
||||
.catch((err) => {
|
||||
if (signal.aborted) return;
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [username, endpoint, cached, token]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||
!username
|
||||
) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
const newItems = items.map(deserializeDump);
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
items: [...s.items, ...newItems],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, username, token, endpoint]);
|
||||
}, [username, cached, authFetch, url]);
|
||||
|
||||
const list = state.status === "loaded" ? state.dumps : null;
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
useLoadMore(list, setDumps, fetchPage),
|
||||
canLoadMore(list),
|
||||
);
|
||||
|
||||
useScrollSave(
|
||||
state.status === "loaded",
|
||||
useCallback((y) => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveState(state.items, state.page, state.hasMore, y);
|
||||
if (state.status === "loaded") saveState(state.dumps, y);
|
||||
}, [state, saveState]),
|
||||
);
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
|
||||
const STORAGE_KEY = "default-feed-tab";
|
||||
|
||||
/** The components reading the tab, told when this tab changes it. */
|
||||
// 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. */
|
||||
// Holds the choice made while storage is blocked, until a reload.
|
||||
let unstored: FeedTab | null = null;
|
||||
|
||||
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* The phase of a fading item, which {@link fadeClassName} maps to a CSS class.
|
||||
* `cooldown` lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS
|
||||
* collapse.
|
||||
* Held by {@link useFading} for each fading item, and mapped to a CSS class by
|
||||
* {@link fadeClassName}. `cooldown` lasts 2 seconds, and `dismissing` lasts
|
||||
* the 350ms of the CSS collapse.
|
||||
*/
|
||||
export type FadeState = "cooldown" | "dismissing";
|
||||
|
||||
@@ -24,16 +24,16 @@ const COOLDOWN_MS = 2000;
|
||||
const DISMISS_MS = 350;
|
||||
|
||||
/**
|
||||
* Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The
|
||||
* Returned by {@link useFading} to `useUpvotedList` and `usePlaylist`. The
|
||||
* list keeps showing an item while it is in `fading`.
|
||||
*/
|
||||
export interface UseFadingResult {
|
||||
fading: Record<string, FadeState>;
|
||||
/** Does nothing when `id` is already fading. */
|
||||
startFading: (id: string) => void;
|
||||
/** Called when the removal is undone: the item stays. */
|
||||
/** Called when the removal is undone, so that the item stays. */
|
||||
cancelFading: (id: string) => void;
|
||||
/** Called when the list is replaced, as by another user's or a reload. */
|
||||
/** Called when the list is replaced, as by another user's upvotes. */
|
||||
cancelAll: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import type { PagedList } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
/** Past this age, a saved feed is refetched rather than restored. */
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -17,9 +18,9 @@ const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
* hook when the page is next reached by going back.
|
||||
*/
|
||||
export interface FeedCacheEntry<T> {
|
||||
/** Every page loaded so far. JSON turns their dates into strings. */
|
||||
/** It holds every page loaded so far, whose dates JSON turns into strings. */
|
||||
items: T[];
|
||||
/** The last page loaded, from which loading more carries on. */
|
||||
/** Loading more carries on from this page, the last loaded. */
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
scrollY: number;
|
||||
@@ -27,20 +28,22 @@ export interface FeedCacheEntry<T> {
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to {@link useFeedCache}'s `saveState` by the feed pages, from the
|
||||
* list they hold, without its loading flag.
|
||||
*/
|
||||
export type SavedList<T> = Pick<PagedList<T>, "items" | "page" | "hasMore">;
|
||||
|
||||
/** Returned by {@link useFeedCache} to a feed page. */
|
||||
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 changes only with the key.
|
||||
* It holds the saved feed when the page was reached by going back or
|
||||
* forward and the 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. */
|
||||
saveState: (
|
||||
items: T[],
|
||||
page: number,
|
||||
hasMore: boolean,
|
||||
scrollY: number,
|
||||
) => void;
|
||||
saveState: (list: SavedList<T>, scrollY: number) => void;
|
||||
}
|
||||
|
||||
/** Returns the entry saved under `key`, or null if none is fresh enough. */
|
||||
@@ -89,20 +92,22 @@ export function useFeedCache<T>(
|
||||
}
|
||||
|
||||
const saveState = useCallback(
|
||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||
({ items, page, hasMore }: SavedList<T>, scrollY: number) => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
fullKey,
|
||||
JSON.stringify({
|
||||
items,
|
||||
page,
|
||||
hasMore,
|
||||
scrollY,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
JSON.stringify(
|
||||
{
|
||||
items,
|
||||
page,
|
||||
hasMore,
|
||||
scrollY,
|
||||
savedAt: Date.now(),
|
||||
} satisfies FeedCacheEntry<T>,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Full or blocked storage: the page just refetches next time.
|
||||
// The page just refetches next time, when storage is full or blocked.
|
||||
}
|
||||
},
|
||||
[fullKey],
|
||||
|
||||
@@ -12,8 +12,7 @@ const MAX_RETRY_DELAY_MS = 30_000;
|
||||
/**
|
||||
* Returns a callback ref for the sentinel element placed after a feed's last
|
||||
* item, and calls `onLoadMore` when the sentinel comes within 200px of the
|
||||
* view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and
|
||||
* not already loading".
|
||||
* view while `enabled`. Feed pages pass `canLoadMore` of their list.
|
||||
*
|
||||
* The ref is a callback so that the observer follows a new sentinel, as when
|
||||
* `Index` switches tabs and another feed mounts its own.
|
||||
|
||||
107
src/hooks/feed/usePagedList.ts
Normal file
107
src/hooks/feed/usePagedList.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Loads a list a page at a time. Every list the API pages goes through
|
||||
* {@link useLoadMore} for its next pages, and a list that loads on its own
|
||||
* through {@link usePagedList}, from its first page on.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import type { PaginatedData } from "../../../shared/api.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
firstPage,
|
||||
mapItems,
|
||||
type PagedList,
|
||||
restoredList,
|
||||
} from "../../utils/feed/pagedList.ts";
|
||||
import type { FeedCacheEntry } from "./useFeedCache.ts";
|
||||
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link useLoadMore} and {@link usePagedList} by a list's page or
|
||||
* hook. It resolves to page `page` of the list, its items deserialized, as
|
||||
* `fetch` with `pageUrl` and `readPage` gives it, and rejects when the page
|
||||
* can't be had.
|
||||
*/
|
||||
export type FetchPage<T> = (
|
||||
page: number,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<PaginatedData<T>>;
|
||||
|
||||
/**
|
||||
* Returns the call that loads the page after `list`'s last one and appends it
|
||||
* through `setList`. It does nothing unless `canLoadMore(list)`; a page that
|
||||
* fails leaves the list as it was, for the next call to try again. The pages
|
||||
* that load their lists with their user (`UserPlaylists`, `useUserDumpFeed`,
|
||||
* the profile's followed users) use it directly, and `usePagedList` for the
|
||||
* others.
|
||||
*/
|
||||
export function useLoadMore<T>(
|
||||
list: PagedList<T> | null,
|
||||
setList: (fn: (list: PagedList<T>) => PagedList<T>) => void,
|
||||
fetchPage: FetchPage<T>,
|
||||
): () => void {
|
||||
// A ref, so that a new `fetchPage` on every render keeps the call stable.
|
||||
const fetchPageRef = useRef(fetchPage);
|
||||
useLayoutEffect(() => {
|
||||
fetchPageRef.current = fetchPage;
|
||||
});
|
||||
|
||||
return useCallback(() => {
|
||||
if (!list || !canLoadMore(list)) return;
|
||||
const page = list.page + 1;
|
||||
setList((l) => ({ ...l, loadingMore: true }));
|
||||
fetchPageRef.current(page)
|
||||
.then(({ items, hasMore }) =>
|
||||
setList((l) => ({
|
||||
items: [...l.items, ...items],
|
||||
hasMore,
|
||||
page,
|
||||
loadingMore: false,
|
||||
}))
|
||||
)
|
||||
.catch(() => setList((l) => ({ ...l, loadingMore: false })));
|
||||
}, [list, setList]);
|
||||
}
|
||||
|
||||
/** Returned by {@link usePagedList}. */
|
||||
export interface UsePagedListResult<T> {
|
||||
state: FetchState<PagedList<T>>;
|
||||
/** Does nothing until loaded. Passed to the sync hooks. */
|
||||
setItems: (fn: (items: T[]) => T[]) => void;
|
||||
/** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list `fetchPage` serves for `key`, from its first page, and the
|
||||
* call that loads the next. `key` changes as `useFetchState`'s does, and
|
||||
* `cached`, `useFeedCache`'s restored list for it, if any, is shown without
|
||||
* loading. The index's feeds, the notifications and the popover of voters and
|
||||
* likers use it.
|
||||
*/
|
||||
export function usePagedList<T>(
|
||||
key: string | undefined,
|
||||
fetchPage: FetchPage<T>,
|
||||
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
|
||||
): UsePagedListResult<T> {
|
||||
const { state, setData } = useFetchState(
|
||||
key,
|
||||
(signal) =>
|
||||
fetchPage(1, signal).then(({ items, hasMore }) =>
|
||||
firstPage(items, hasMore)
|
||||
),
|
||||
{ initial: cached ? restoredList(cached) : undefined },
|
||||
);
|
||||
const setItems = useCallback(
|
||||
(fn: (items: T[]) => T[]) => setData(mapItems(fn)),
|
||||
[setData],
|
||||
);
|
||||
const loadMore = useLoadMore(
|
||||
state.status === "loaded" ? state.data : null,
|
||||
setData,
|
||||
fetchPage,
|
||||
);
|
||||
return { state, setItems, loadMore };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Puts an item that left a profile's list, by turning private, back at its
|
||||
* old position when it turns public again.
|
||||
* Puts an item that left a list, as by turning private, back at its old
|
||||
* position when it turns public again.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,8 +10,8 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
/**
|
||||
* 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
|
||||
* `shouldReinsert` accepts it. The profile pages and `usePlaylist` 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.
|
||||
*/
|
||||
|
||||
334
src/hooks/playlist/usePlaylist.ts
Normal file
334
src/hooks/playlist/usePlaylist.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Loads a playlist with its dumps for its page, keeps them live, and makes the
|
||||
* changes its owner or a moderator makes there.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
ReorderPlaylistRequest,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { playlistUrl } from "../../utils/shared/urls.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
||||
import { type FadeState, useFading } from "../feed/useFading.ts";
|
||||
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
|
||||
import { useDumpListSync } from "../dump/useDumpListSync.ts";
|
||||
|
||||
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
|
||||
export interface UsePlaylistResult {
|
||||
state: FetchState<PlaylistWithDumps>;
|
||||
/**
|
||||
* It holds the dumps being removed, which stay listed while they fade. A
|
||||
* dump in it can't be dragged, and during its `cooldown` its removal can be
|
||||
* undone.
|
||||
*/
|
||||
fading: Record<string, FadeState>;
|
||||
/** It says why the server refused a removal, a reorder or a delete. */
|
||||
actionError: string | null;
|
||||
/** Shows the playlist as `PlaylistEditForm` saved it. */
|
||||
applyDetails: (playlist: Playlist) => void;
|
||||
/**
|
||||
* Fades the dump out, and takes it out of the playlist on the server when
|
||||
* the fade ends, or when the page is left before.
|
||||
*/
|
||||
remove: (dumpId: string) => void;
|
||||
/** Keeps a dump that `remove` is fading out. Nothing was sent yet. */
|
||||
undoRemove: (dumpId: string) => void;
|
||||
/**
|
||||
* Moves the dump at `from` to `to`, counted among the dumps not fading, on
|
||||
* screen only, as a drag goes along.
|
||||
*/
|
||||
move: (from: number, to: number) => void;
|
||||
/** Sends the order `move` left, as a drag ends. */
|
||||
saveOrder: () => void;
|
||||
/** Leaves for home once the playlist is deleted, or sets `actionError`. */
|
||||
deletePlaylist: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `items` with those that `ids` names put in its order, in the slots
|
||||
* those items held. The others, as the dumps fading out, keep their slots.
|
||||
*/
|
||||
function inOrder<T extends { id: string }>(items: T[], ids: string[]): T[] {
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
const ordered = ids.filter((id) => byId.has(id)).map((id) => byId.get(id)!);
|
||||
const named = new Set(ordered.map((i) => i.id));
|
||||
let next = 0;
|
||||
return items.map((i) => named.has(i.id) ? ordered[next++] : i);
|
||||
}
|
||||
|
||||
/** Returns `playlist` with the details of `updated`, keeping its dumps. */
|
||||
function withDetails(
|
||||
playlist: PlaylistWithDumps,
|
||||
updated: Playlist,
|
||||
): PlaylistWithDumps {
|
||||
return {
|
||||
...playlist,
|
||||
...updated,
|
||||
// Set only where the dumps were counted, which an edit's answer isn't.
|
||||
ownerUsername: updated.ownerUsername ?? playlist.ownerUsername,
|
||||
dumps: playlist.dumps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the playlist `playlistId` (its id or its slug) from
|
||||
* `GET /api/playlists/:playlistId`, with the dumps the viewer may see in it.
|
||||
* The socket's events keep its details and dumps live: a dump another client
|
||||
* removes fades out, one it adds is fetched, a new order is applied, one that
|
||||
* goes private leaves and comes back at its place, and a deleted playlist
|
||||
* sends the page home. A new slug replaces the URL.
|
||||
*/
|
||||
export function usePlaylist(
|
||||
playlistId: string | undefined,
|
||||
): UsePlaylistResult {
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { lastDumpEvent, lastPlaylistEvent } = useWS();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// The server's last order of the dumps this viewer may see, which a refused
|
||||
// reorder goes back to.
|
||||
const serverOrderRef = useRef<string[]>([]);
|
||||
const { state, setData } = useFetchState(
|
||||
playlistId && `${playlistId}:${user?.id ?? "guest"}`,
|
||||
(signal) =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}`, { signal })
|
||||
.then(expectOk<Wire<PlaylistWithDumps>>)
|
||||
.then((wire) => {
|
||||
const playlist = deserializePlaylistWithDumps(wire);
|
||||
serverOrderRef.current = playlist.dumps.map((d) => d.id);
|
||||
return playlist;
|
||||
}),
|
||||
);
|
||||
const playlist = state.status === "loaded" ? state.data : null;
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const id = playlist?.id;
|
||||
const url = `${API_URL}/api/playlists/${id}`;
|
||||
|
||||
const setDumps = useCallback(
|
||||
(fn: (dumps: Dump[]) => Dump[]) =>
|
||||
setData((p) => ({ ...p, dumps: fn(p.dumps) })),
|
||||
[setData],
|
||||
);
|
||||
|
||||
// This viewer's removals whose fade hasn't ended, with the dump and its
|
||||
// place, to put it back if the server refuses.
|
||||
const pendingRef = useRef(new Map<string, { dump: Dump; index: number }>());
|
||||
// Every dump taken out of the playlist, by anyone, which mustn't come back
|
||||
// when it next changes, as one that went private does.
|
||||
const removedRef = useRef(new Set<string>());
|
||||
|
||||
const sendRemoval = useCallback((dumpId: string) => {
|
||||
const pending = pendingRef.current.get(dumpId);
|
||||
if (!pending) return;
|
||||
pendingRef.current.delete(dumpId);
|
||||
// `keepalive`: it may be sent as the page is left.
|
||||
authFetch(`${url}/dumps/${dumpId}`, { method: "DELETE", keepalive: true })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
})
|
||||
.catch(() => {
|
||||
removedRef.current.delete(dumpId);
|
||||
setDumps((dumps) => {
|
||||
if (dumps.some((d) => d.id === dumpId)) return dumps;
|
||||
const next = [...dumps];
|
||||
next.splice(Math.min(pending.index, next.length), 0, pending.dump);
|
||||
return next;
|
||||
});
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
});
|
||||
}, [authFetch, url, setDumps]);
|
||||
|
||||
const { fading, startFading, cancelFading } = useFading((dumpId) => {
|
||||
setDumps((dumps) => dumps.filter((d) => d.id !== dumpId));
|
||||
sendRemoval(dumpId);
|
||||
});
|
||||
|
||||
// For the effects, which must run on new events only, and the calls below,
|
||||
// which must stay stable.
|
||||
const latestRef = useRef({
|
||||
playlist,
|
||||
fading,
|
||||
startFading,
|
||||
cancelFading,
|
||||
sendRemoval,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
latestRef.current = {
|
||||
playlist,
|
||||
fading,
|
||||
startFading,
|
||||
cancelFading,
|
||||
sendRemoval,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () =>
|
||||
pendingRef.current.forEach((_, dumpId) =>
|
||||
latestRef.current.sendRemoval(dumpId)
|
||||
);
|
||||
globalThis.addEventListener("pagehide", flush);
|
||||
return () => {
|
||||
globalThis.removeEventListener("pagehide", flush);
|
||||
flush();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyDetails = useCallback((updated: Playlist) => {
|
||||
const slug = latestRef.current.playlist?.slug;
|
||||
setData((p) => withDetails(p, updated));
|
||||
if (updated.slug && updated.slug !== slug) {
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
}
|
||||
}, [setData, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const ev = lastPlaylistEvent;
|
||||
if (!ev || !id || ev.playlistId !== id) return;
|
||||
if (ev.type === "updated" && ev.playlist) {
|
||||
applyDetails(ev.playlist);
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
} else if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const ids = ev.dumpIds;
|
||||
const { playlist, startFading, cancelFading } = latestRef.current;
|
||||
serverOrderRef.current = ids;
|
||||
const listed = new Set(ids);
|
||||
const shown = new Set(playlist?.dumps.map((d) => d.id));
|
||||
for (const dumpId of shown) {
|
||||
if (listed.has(dumpId)) continue;
|
||||
removedRef.current.add(dumpId);
|
||||
startFading(dumpId);
|
||||
}
|
||||
for (const dumpId of ids) {
|
||||
// This viewer's own removal, which the server hasn't had yet.
|
||||
if (pendingRef.current.has(dumpId)) continue;
|
||||
removedRef.current.delete(dumpId);
|
||||
if (shown.has(dumpId)) {
|
||||
// Put back while it was fading out.
|
||||
cancelFading(dumpId);
|
||||
continue;
|
||||
}
|
||||
authFetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||
.then(expectOk<Wire<Dump>>)
|
||||
.then((wire) => {
|
||||
const dump = deserializeDump(wire);
|
||||
setDumps((dumps) =>
|
||||
dumps.some((d) => d.id === dump.id)
|
||||
? dumps
|
||||
: inOrder([...dumps, dump], serverOrderRef.current)
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setDumps((dumps) => inOrder(dumps, ids));
|
||||
}
|
||||
}, [lastPlaylistEvent, id, applyDetails, navigate, authFetch, setDumps]);
|
||||
|
||||
// Before `useDumpListSync`: it notes a dump's place before the sync takes
|
||||
// it out, as when it goes private.
|
||||
usePositionAwareSync(
|
||||
playlist?.dumps ?? [],
|
||||
setDumps,
|
||||
lastDumpEvent,
|
||||
(d) => !d.isPrivate && !removedRef.current.has(d.id),
|
||||
);
|
||||
// A private dump is listed only to the playlist's owner, and only their own,
|
||||
// which is the only private dump whose changes reach them.
|
||||
useDumpListSync(setDumps, {
|
||||
isOwner: !!user && user.id === playlist?.userId,
|
||||
});
|
||||
|
||||
const remove = useCallback((dumpId: string) => {
|
||||
const dumps = latestRef.current.playlist?.dumps ?? [];
|
||||
const index = dumps.findIndex((d) => d.id === dumpId);
|
||||
if (index === -1) return;
|
||||
pendingRef.current.set(dumpId, { dump: dumps[index], index });
|
||||
removedRef.current.add(dumpId);
|
||||
startFading(dumpId);
|
||||
}, [startFading]);
|
||||
|
||||
const undoRemove = useCallback((dumpId: string) => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
removedRef.current.delete(dumpId);
|
||||
cancelFading(dumpId);
|
||||
}, [cancelFading]);
|
||||
|
||||
const move = useCallback((from: number, to: number) => {
|
||||
const { fading } = latestRef.current;
|
||||
setDumps((dumps) => {
|
||||
const ids = dumps.filter((d) => !(d.id in fading)).map((d) => d.id);
|
||||
const [moved] = ids.splice(from, 1);
|
||||
ids.splice(to, 0, moved);
|
||||
return inOrder(dumps, ids);
|
||||
});
|
||||
}, [setDumps]);
|
||||
|
||||
const saveOrder = useCallback(() => {
|
||||
// The server wants every dump it lists to this viewer, those whose
|
||||
// removal isn't sent yet included.
|
||||
const onServer = new Set(serverOrderRef.current);
|
||||
const dumpIds = (latestRef.current.playlist?.dumps ?? [])
|
||||
.map((d) => d.id)
|
||||
.filter((dumpId) => onServer.has(dumpId));
|
||||
authFetch(`${url}/order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ dumpIds } satisfies ReorderPlaylistRequest),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
})
|
||||
.catch(() => {
|
||||
setDumps((dumps) => inOrder(dumps, serverOrderRef.current));
|
||||
setActionError(t`The new order couldn't be saved. Try again.`);
|
||||
});
|
||||
}, [authFetch, url, setDumps]);
|
||||
|
||||
const deletePlaylist = useCallback(async () => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await authFetch(url, { method: "DELETE" });
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
}, [authFetch, url, navigate]);
|
||||
|
||||
return {
|
||||
state,
|
||||
fading,
|
||||
actionError,
|
||||
applyDetails,
|
||||
remove,
|
||||
undoRemove,
|
||||
move,
|
||||
saveOrder,
|
||||
deletePlaylist,
|
||||
};
|
||||
}
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
|
||||
import type { LoadState } from "../../utils/shared/loadState.ts";
|
||||
import {
|
||||
fetchFirstPage,
|
||||
type PagedList,
|
||||
pageUrl,
|
||||
readFirstPage,
|
||||
restoredList,
|
||||
} from "../../utils/feed/pagedList.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
@@ -36,8 +37,8 @@ import { useUpvotedList } from "../dump/useUpvotedList.ts";
|
||||
import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts";
|
||||
|
||||
/**
|
||||
* The profile's lists, each holding its first page only: each has its own page
|
||||
* for the rest.
|
||||
* Loaded by {@link useProfile} for `UserPublicProfile`. Each list holds its
|
||||
* first page only, and has its own page for the rest.
|
||||
*/
|
||||
export interface ProfileData {
|
||||
user: PublicUser;
|
||||
@@ -49,13 +50,19 @@ export interface ProfileData {
|
||||
/** Returned by {@link useProfile} to `UserPublicProfile`. */
|
||||
export interface UseProfileResult {
|
||||
state: LoadState<ProfileData>;
|
||||
/** Whether the signed-in user is the profile's. False while it loads. */
|
||||
/** It is false while the profile loads. */
|
||||
isOwnProfile: boolean;
|
||||
/** For the header's edits, which the server echoes to other browsers. */
|
||||
/** It shows the header's edits, which the server echoes to other browsers. */
|
||||
setUser: (fn: (user: PublicUser) => PublicUser) => void;
|
||||
/** For `NewPlaylistButton`, whose playlist the socket echoes later. */
|
||||
/**
|
||||
* It shows the playlist `NewPlaylistButton` made, which the socket echoes
|
||||
* later.
|
||||
*/
|
||||
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
|
||||
/** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */
|
||||
/**
|
||||
* It comes from `useUpvotedList`, and goes to the upvoted list's
|
||||
* `DumpCardList`.
|
||||
*/
|
||||
upvotedCardClassName: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
@@ -81,7 +88,7 @@ function updateList<K extends ListKey>(
|
||||
* refetching only the user.
|
||||
*/
|
||||
export function useProfile(username: string | undefined): UseProfileResult {
|
||||
const { user: me, token } = useAuth();
|
||||
const { user: me, authFetch } = useAuth();
|
||||
const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS();
|
||||
|
||||
const dumpsCache = useFeedCache<Dump>(
|
||||
@@ -174,14 +181,10 @@ export function useProfile(username: string | undefined): UseProfileResult {
|
||||
if (!username) return;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const headers: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
const firstPageOf = <T>(list: string, deserialize: (w: Wire<T>) => T) =>
|
||||
fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, {
|
||||
headers,
|
||||
authFetch(pageUrl(`${API_URL}/api/users/${username}/${list}`, 1), {
|
||||
signal,
|
||||
});
|
||||
}).then((res) => readFirstPage(res, deserialize));
|
||||
|
||||
// Back on the page, the lists come from the cache and only the user is
|
||||
// fetched again.
|
||||
@@ -206,7 +209,7 @@ export function useProfile(username: string | undefined): UseProfileResult {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
|
||||
}, [username, cachedDumps, cachedVotes, cachedPlaylists, authFetch]);
|
||||
|
||||
const saveDumps = dumpsCache.saveState;
|
||||
const saveVotes = votesCache.saveState;
|
||||
@@ -216,9 +219,9 @@ export function useProfile(username: string | undefined): UseProfileResult {
|
||||
useCallback((y) => {
|
||||
if (state.status !== "loaded") return;
|
||||
const { dumps, votes, playlists } = state;
|
||||
saveDumps(dumps.items, dumps.page, dumps.hasMore, y);
|
||||
saveVotes(votes.items, votes.page, votes.hasMore, y);
|
||||
savePlaylists(playlists.items, playlists.page, playlists.hasMore, y);
|
||||
saveDumps(dumps, y);
|
||||
saveVotes(votes, y);
|
||||
savePlaylists(playlists, y);
|
||||
}, [state, saveDumps, saveVotes, savePlaylists]),
|
||||
);
|
||||
useScrollRestore(cachedDumps?.scrollY, state.status === "loaded");
|
||||
|
||||
@@ -17,31 +17,58 @@ import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
/** Held by {@link useFetchState}, and passed on to `ListState`. */
|
||||
export type FetchState<T> = LoadState<{ data: T }>;
|
||||
|
||||
/** Passed to {@link useFetchState}. */
|
||||
export interface FetchStateOptions<T> {
|
||||
/**
|
||||
* False until it should load, as the profile's tabs pass "this tab is open".
|
||||
* Turning it off doesn't abort a load under way, so those tabs finish
|
||||
* loading in the background. True by default.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* It holds the data already at hand for a new key, as a feed restored from
|
||||
* `useFeedCache`, which starts loaded and isn't loaded again. It is read
|
||||
* only when the key changes.
|
||||
*/
|
||||
initial?: T;
|
||||
}
|
||||
|
||||
/** Returned by {@link useFetchState}. */
|
||||
export interface UseFetchStateResult<T> {
|
||||
state: FetchState<T>;
|
||||
/** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */
|
||||
/**
|
||||
* Edits the loaded data, as when a next page comes in. It does nothing until
|
||||
* loaded.
|
||||
*/
|
||||
setData: (fn: (data: T) => T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns what `load` resolves to for `key`, loaded the first time `enabled`
|
||||
* is true, and kept afterwards. Turning `enabled` off doesn't abort the load,
|
||||
* so the profile's tabs, which pass "this tab is open", finish loading in the
|
||||
* background. A new `key` starts over from loading and aborts the load of the
|
||||
* last one, as leaving the page does; an undefined key loads nothing. `load`
|
||||
* must reject with an `Error`, as `expectOk` does, whose message it shows.
|
||||
* Returns what `load` resolves to for `key`, loaded the first time it is
|
||||
* enabled, and kept afterwards. A new `key` starts over and aborts the load of
|
||||
* the last one, as leaving the page does; an undefined key loads nothing. The
|
||||
* key must change with whatever the load depends on, the viewer included when
|
||||
* it is signed in. `load` must reject with an `Error`, as `expectOk` does,
|
||||
* whose message it shows.
|
||||
*/
|
||||
export function useFetchState<T>(
|
||||
key: string | undefined,
|
||||
load: (signal: AbortSignal) => Promise<T>,
|
||||
enabled = true,
|
||||
{ enabled = true, initial }: FetchStateOptions<T> = {},
|
||||
): UseFetchStateResult<T> {
|
||||
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
|
||||
const [prevKey, setPrevKey] = useState(key);
|
||||
if (prevKey !== key) {
|
||||
setPrevKey(key);
|
||||
setState({ status: "loading" });
|
||||
const startState = (): FetchState<T> =>
|
||||
initial === undefined
|
||||
? { status: "loading" }
|
||||
: { status: "loaded", data: initial };
|
||||
const [state, setState] = useState<FetchState<T>>(startState);
|
||||
// `restored`: the key's data came from `initial`, and isn't loaded.
|
||||
const [current, setCurrent] = useState({
|
||||
key,
|
||||
restored: initial !== undefined,
|
||||
});
|
||||
if (current.key !== key) {
|
||||
setCurrent({ key, restored: initial !== undefined });
|
||||
setState(startState());
|
||||
}
|
||||
|
||||
// A ref, so that a new `load` on every render doesn't load again.
|
||||
@@ -59,7 +86,10 @@ export function useFetchState<T>(
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || key === undefined || startedRef.current === key) return;
|
||||
if (
|
||||
!enabled || key === undefined || current.restored ||
|
||||
startedRef.current === key
|
||||
) return;
|
||||
startedRef.current = key;
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
@@ -69,7 +99,7 @@ export function useFetchState<T>(
|
||||
if (controller.signal.aborted) return;
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
}, [enabled, key]);
|
||||
}, [enabled, key, current.restored]);
|
||||
|
||||
const setData = useCallback((fn: (data: T) => T) => {
|
||||
setState((s) =>
|
||||
|
||||
Reference in New Issue
Block a user