diff --git a/src/features/library/hooks/index.ts b/src/features/library/hooks/index.ts index 89501331..38cac4b4 100644 --- a/src/features/library/hooks/index.ts +++ b/src/features/library/hooks/index.ts @@ -8,9 +8,11 @@ export { useExternalItems, type EntityType } from "./useExternalItems"; export { useLikedTracks, useLikedTrackCount, +} from "./useLikedTracks"; +export { applyOptimisticLike, applyOptimisticUnlike, -} from "./useLikedTracks"; +} from "./likes/optimisticLikes"; export { useYoutubeLibraryItems } from "./useYoutubeLibraryItems"; export { useIsTrackLiked } from "./useIsTrackLiked"; export { @@ -21,6 +23,6 @@ export { useDeletePlaylist, useSaveExternalItem, useRemoveExternalItem, - useLikeTrack, - useUnlikeTrack, } from "./useLibraryMutations"; +export { useLikeTrack } from "./likes/useLikeTrack"; +export { useUnlikeTrack } from "./likes/useUnlikeTrack"; diff --git a/src/features/library/hooks/likes/accountScope.test.ts b/src/features/library/hooks/likes/accountScope.test.ts new file mode 100644 index 00000000..61a8672b --- /dev/null +++ b/src/features/library/hooks/likes/accountScope.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const reasonOf = (result: PromiseSettledResult) => + result.status === "rejected" ? result.reason : undefined; + +const auth = vi.hoisted(() => ({ + session: null as { accessToken: string; user: { id: string } } | null, +})); + +vi.mock("@/shared/api", () => ({ getAuthSession: () => auth.session })); +vi.mock("./notifyLibraryChanged", () => ({ notifyLibraryChanged: vi.fn() })); + +const signIn = (id: string, accessToken = "token") => { + auth.session = { accessToken, user: { id } }; +}; +const signOut = () => { + auth.session = null; +}; + +async function setup() { + vi.resetModules(); + const scope = await import("./accountScope"); + const likes = await import("./optimisticLikes"); + const queue = await import("./mutationQueue"); + const { runTrackMutation } = await import("./runTrackMutation"); + return { scope, likes, queue, runTrackMutation }; +} + +beforeEach(signOut); + +describe("cancelling like/unlike work when the account changes", () => { + it("cancels queued and in-flight work when another account signs in", async () => { + const { scope, likes, queue, runTrackMutation } = await setup(); + signIn("a"); + const inFlight = deferred(); + const call = vi + .fn() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(undefined); + + const like = runTrackMutation("t1", likes.applyOptimisticLike, call); + const unlike = runTrackMutation("t1", likes.applyOptimisticUnlike, call); + await flush(); + expect(call).toHaveBeenCalledTimes(1); + + signIn("b"); + scope.syncAccountScope(); + const results = await Promise.allSettled([like, unlike]); + inFlight.resolve(); + await flush(); + + for (const result of results) { + expect(reasonOf(result)).toBeInstanceOf(queue.StaleSessionError); + } + expect(call).toHaveBeenCalledTimes(1); + expect(likes.hasOverrides()).toBe(false); + }); + + it("does not resume old work after sign-out and sign-in as the same account", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a"); + const inFlight = deferred(); + const call = vi + .fn() + .mockReturnValueOnce(inFlight.promise) + .mockResolvedValue(undefined); + const like = runTrackMutation("t1", likes.applyOptimisticLike, call); + const unlike = runTrackMutation("t1", likes.applyOptimisticUnlike, call); + await flush(); + + signOut(); + scope.syncAccountScope(); + signIn("a"); + scope.syncAccountScope(); + const results = await Promise.allSettled([like, unlike]); + inFlight.resolve(); + await flush(); + + expect(results.map((r) => r.status)).toEqual(["rejected", "rejected"]); + expect(call).toHaveBeenCalledTimes(1); + expect(likes.hasOverrides()).toBe(false); + }); + + it("aborts the signal handed to the API call when the account changes", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a"); + const inFlight = deferred(); + const seen: AbortSignal[] = []; + const run = runTrackMutation("t1", likes.applyOptimisticLike, (_id, signal) => { + seen.push(signal); + return inFlight.promise; + }); + await flush(); + expect(seen[0]?.aborted).toBe(false); + + signIn("b"); + scope.syncAccountScope(); + await Promise.allSettled([run]); + + expect(seen[0]?.aborted).toBe(true); + }); + + it("rejects a mutation started while signed out", async () => { + const { likes, queue, runTrackMutation } = await setup(); + const call = vi.fn(); + + await expect( + runTrackMutation("t1", likes.applyOptimisticLike, call), + ).rejects.toBeInstanceOf(queue.StaleSessionError); + expect(call).not.toHaveBeenCalled(); + expect(likes.hasOverrides()).toBe(false); + }); + + it("lets the next account mutate normally after the previous one left", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a"); + const never = deferred(); + const a = runTrackMutation("t1", likes.applyOptimisticLike, () => never.promise); + await flush(); + + signOut(); + scope.syncAccountScope(); + signIn("b"); + scope.syncAccountScope(); + await Promise.allSettled([a]); + + const call = vi.fn().mockResolvedValue("ok"); + await expect( + runTrackMutation("t1", likes.applyOptimisticLike, call), + ).resolves.toBe("ok"); + expect(call).toHaveBeenCalledTimes(1); + expect(likes.getOverrideLiked("t1")).toBe(true); + }); +}); + +describe("settling like/unlike work across account changes", () => { + it("does not let a stale completion touch the new account's override", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a"); + const lateA = deferred(); + const a = runTrackMutation("t1", likes.applyOptimisticLike, () => lateA.promise); + await flush(); + + signIn("b"); + scope.syncAccountScope(); + const holdB = deferred(); + const b = runTrackMutation("t1", likes.applyOptimisticLike, () => holdB.promise); + + lateA.reject(new Error("late failure of account a")); + await Promise.allSettled([a]); + expect(likes.getOverrideLiked("t1")).toBe(true); + + holdB.resolve(); + await b; + expect(likes.getOverrideLiked("t1")).toBe(true); + }); + + it("drops a settled override that was not reconciled when the account changes", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a"); + await runTrackMutation("t1", likes.applyOptimisticLike, () => Promise.resolve()); + expect(likes.getOverrideLiked("t1")).toBe(true); + + signIn("b"); + scope.syncAccountScope(); + + expect(likes.getOverrideLiked("t1")).toBeUndefined(); + expect(likes.hasOverrides()).toBe(false); + }); + + it("keeps work alive when the token refreshes for the same account", async () => { + const { scope, likes, runTrackMutation } = await setup(); + signIn("a", "token-1"); + const inFlight = deferred(); + const run = runTrackMutation("t1", likes.applyOptimisticLike, () => inFlight.promise); + await flush(); + + signIn("a", "token-2"); + scope.syncAccountScope(); + inFlight.resolve("ok"); + + await expect(run).resolves.toBe("ok"); + }); + + it("keeps like and unlike of one track in order", async () => { + const { likes, runTrackMutation } = await setup(); + signIn("a"); + const order: string[] = []; + const first = deferred(); + + const like = runTrackMutation("t1", likes.applyOptimisticLike, async () => { + order.push("like:start"); + await first.promise; + order.push("like:end"); + }); + const unlike = runTrackMutation("t1", likes.applyOptimisticUnlike, async () => { + order.push("unlike:start"); + }); + await flush(); + expect(order).toEqual(["like:start"]); + + first.resolve(); + await Promise.all([like, unlike]); + expect(order).toEqual(["like:start", "like:end", "unlike:start"]); + }); +}); diff --git a/src/features/library/hooks/likes/accountScope.ts b/src/features/library/hooks/likes/accountScope.ts new file mode 100644 index 00000000..c412076f --- /dev/null +++ b/src/features/library/hooks/likes/accountScope.ts @@ -0,0 +1,56 @@ +import { getAuthSession } from "@/shared/api"; + +export type AccountScope = { + accountId: string; + signal: AbortSignal; +}; + +type Active = { + accountId: string | null; + controller: AbortController; +}; + +const listeners = new Set<() => void>(); +let active: Active | undefined; + +export const currentAccountId = (): string | null => + getAuthSession()?.user.id ?? null; + +function sync(): Active { + const accountId = currentAccountId(); + if (!active) { + active = { accountId, controller: new AbortController() }; + return active; + } + if (active.accountId !== accountId) { + active.controller.abort(); + active = { accountId, controller: new AbortController() }; + listeners.forEach((listener) => listener()); + } + return active; +} + +export function syncAccountScope() { + sync(); +} + +export function onAccountChange(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function captureScope(): AccountScope | null { + const { accountId, controller } = sync(); + return accountId ? { accountId, signal: controller.signal } : null; +} + +export function isScopeActive(scope: AccountScope): boolean { + sync(); + return !scope.signal.aborted; +} + +if (typeof window !== "undefined") { + window.addEventListener("auth:changed", syncAccountScope); +} diff --git a/src/features/library/hooks/likes/buildLikedView.ts b/src/features/library/hooks/likes/buildLikedView.ts new file mode 100644 index 00000000..9defafa4 --- /dev/null +++ b/src/features/library/hooks/likes/buildLikedView.ts @@ -0,0 +1,34 @@ +import type { Track } from "@/shared/types"; +import { + getOverrideLiked, + hasOverrides, + overrideEntries, +} from "./optimisticLikes"; + +export type LikedData = { tracks: Track[]; total: number }; + +export function buildLikedView( + cache: LikedData, + isLoading: boolean, +): { data: LikedData; isLoading: boolean } { + if (!hasOverrides()) return { data: cache, isLoading }; + + let tracks = cache.tracks.filter((t) => getOverrideLiked(t.id) !== false); + for (const [id, liked] of overrideEntries()) { + if (liked && !tracks.some((t) => t.id === id)) { + tracks = [ + { + id, + title: "", + artists: "", + artistId: undefined, + coverUrl: "", + durationMs: 0, + playCount: 0, + } as Track, + ...tracks, + ]; + } + } + return { data: { tracks, total: tracks.length }, isLoading }; +} diff --git a/src/features/library/hooks/likes/fetchLikedTracks.test.ts b/src/features/library/hooks/likes/fetchLikedTracks.test.ts new file mode 100644 index 00000000..d44e6154 --- /dev/null +++ b/src/features/library/hooks/likes/fetchLikedTracks.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const auth = vi.hoisted(() => ({ + session: null as { accessToken: string; user: { id: string } } | null, + listLikedTracks: vi.fn(), +})); + +vi.mock("@/shared/api", () => ({ + getAuthSession: () => auth.session, + api: { listLikedTracks: auth.listLikedTracks }, + toClientTrack: (track: object) => track, +})); + +const signIn = (id: string) => { + auth.session = { accessToken: "token", user: { id } }; +}; + +async function setup() { + vi.resetModules(); + const scope = await import("./accountScope"); + const { fetchLikedTracks } = await import("./fetchLikedTracks"); + const current = scope.captureScope(); + if (!current) throw new Error("expected a signed-in scope"); + return { scope, fetchLikedTracks, current }; +} + +beforeEach(() => { + auth.session = null; + auth.listLikedTracks.mockReset(); +}); + +describe("liked tracks fetch across account changes", () => { + it("returns every page while the account stays the same", async () => { + signIn("a"); + auth.listLikedTracks + .mockResolvedValueOnce({ + items: [{ track: { id: "1" }, likedAt: "x" }], + nextCursor: "next", + }) + .mockResolvedValueOnce({ + items: [{ track: { id: "2" }, likedAt: "y" }], + nextCursor: null, + }); + const { fetchLikedTracks, current } = await setup(); + + const tracks = await fetchLikedTracks(current); + + expect(tracks?.map((t) => t.id)).toEqual(["1", "2"]); + }); + + it("drops the page and stops paging once the account changed", async () => { + signIn("a"); + let release!: (page: unknown) => void; + auth.listLikedTracks.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const { scope, fetchLikedTracks, current } = await setup(); + const result = fetchLikedTracks(current); + + signIn("b"); + scope.syncAccountScope(); + release({ + items: [{ track: { id: "private-to-a" }, likedAt: "x" }], + nextCursor: "next", + }); + + await expect(result).resolves.toBeNull(); + expect(auth.listLikedTracks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/library/hooks/likes/fetchLikedTracks.ts b/src/features/library/hooks/likes/fetchLikedTracks.ts new file mode 100644 index 00000000..b106bc43 --- /dev/null +++ b/src/features/library/hooks/likes/fetchLikedTracks.ts @@ -0,0 +1,22 @@ +import { api, toClientTrack } from "@/shared/api"; +import type { Track } from "@/shared/types"; +import { isScopeActive, type AccountScope } from "./accountScope"; + +export async function fetchLikedTracks( + scope: AccountScope, +): Promise { + const tracks: Track[] = []; + let cursor: string | undefined; + do { + const page = await api.listLikedTracks(100, cursor); + if (!isScopeActive(scope)) return null; + tracks.push( + ...page.items.map(({ track, likedAt }) => ({ + ...toClientTrack(track), + addedAt: likedAt, + })), + ); + cursor = page.nextCursor ?? undefined; + } while (cursor); + return tracks; +} diff --git a/src/features/library/hooks/likes/mutationQueue.ts b/src/features/library/hooks/likes/mutationQueue.ts new file mode 100644 index 00000000..e384ea12 --- /dev/null +++ b/src/features/library/hooks/likes/mutationQueue.ts @@ -0,0 +1,78 @@ +import { isScopeActive, type AccountScope } from "./accountScope.ts"; +import { notifyLibraryChanged } from "../usePlaylists.ts"; + +export class StaleSessionError extends Error { + constructor() { + super("Session changed"); + this.name = "StaleSessionError"; + } +} + +export type MutateOptions = { + onSuccess?: (value: R) => void; + onError?: (error: unknown) => void; + onSettled?: () => void; +}; + +export type TrackMutationInput = + | string + | { trackId: string; [key: string]: unknown }; + +export const trackIdFrom = (input: TrackMutationInput) => + typeof input === "string" ? input : input.trackId; + +const NOTIFY_DEBOUNCE_MS = 300; + +let inflight = 0; +let notifyTimer: ReturnType | undefined; + +export function beginLibraryMutation() { + inflight++; + clearTimeout(notifyTimer); +} + +export function endLibraryMutation() { + inflight = Math.max(0, inflight - 1); + clearTimeout(notifyTimer); + if (inflight > 0) return; + + notifyTimer = setTimeout(() => { + if (inflight === 0) notifyLibraryChanged(); + }, NOTIFY_DEBOUNCE_MS); +} + +function rejectOnAbort(signal: AbortSignal, work: Promise): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => reject(new StaleSessionError()); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + work + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", onAbort)); + }); +} + +const queues = new Map>(); + +export function runSerial( + trackId: string, + scope: AccountScope, + task: (signal: AbortSignal) => Promise, +): Promise { + const prev = queues.get(trackId) ?? Promise.resolve(); + const work = prev + .catch(() => undefined) + .then(() => { + if (!isScopeActive(scope)) throw new StaleSessionError(); + return task(scope.signal); + }); + const next = rejectOnAbort(scope.signal, work); + queues.set(trackId, next); + + const cleanup = () => { + if (queues.get(trackId) === next) queues.delete(trackId); + }; + next.then(cleanup, cleanup); + + return next; +} diff --git a/src/features/library/hooks/likes/optimisticLikes.ts b/src/features/library/hooks/likes/optimisticLikes.ts new file mode 100644 index 00000000..036fd78d --- /dev/null +++ b/src/features/library/hooks/likes/optimisticLikes.ts @@ -0,0 +1,83 @@ +import { onAccountChange, type AccountScope } from "./accountScope"; + +type Override = { + liked: boolean; + pending: number; + settledSeq: number; + signal: AbortSignal; +}; + +const overrides = new Map(); +const listeners = new Set<() => void>(); +let settleSeq = 0; + +function notify() { + listeners.forEach((l) => l()); +} + +export function onOverridesChange(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export const hasOverrides = () => overrides.size > 0; + +export const getOverrideLiked = (trackId: string): boolean | undefined => + overrides.get(trackId)?.liked; + +export function* overrideEntries(): Generator<[string, boolean]> { + for (const [id, o] of overrides) yield [id, o.liked]; +} + +export const currentSettleSeq = () => settleSeq; + +function apply(trackId: string, liked: boolean, scope: AccountScope) { + if (scope.signal.aborted) return; + const cur = overrides.get(trackId); + const own = cur?.signal === scope.signal ? cur : undefined; + overrides.set(trackId, { + liked, + pending: (own?.pending ?? 0) + 1, + settledSeq: own?.settledSeq ?? 0, + signal: scope.signal, + }); + notify(); +} + +export function applyOptimisticLike(trackId: string, scope: AccountScope) { + apply(trackId, true, scope); +} + +export function applyOptimisticUnlike(trackId: string, scope: AccountScope) { + apply(trackId, false, scope); +} + +export function settleOptimistic( + trackId: string, + ok: boolean, + scope: AccountScope, +) { + const o = overrides.get(trackId); + if (!o || o.signal !== scope.signal) return; + + o.pending--; + o.settledSeq = ++settleSeq; + + if (o.pending === 0 && !ok) overrides.delete(trackId); + notify(); +} + +export function reconcileOverrides(fetchStartedSeq: number) { + for (const [id, o] of overrides) { + if (o.pending === 0 && o.settledSeq <= fetchStartedSeq) { + overrides.delete(id); + } + } +} + +onAccountChange(() => { + overrides.clear(); + notify(); +}); diff --git a/src/features/library/hooks/likes/runTrackMutation.ts b/src/features/library/hooks/likes/runTrackMutation.ts new file mode 100644 index 00000000..199dae66 --- /dev/null +++ b/src/features/library/hooks/likes/runTrackMutation.ts @@ -0,0 +1,32 @@ +import { captureScope, type AccountScope } from "./accountScope"; +import { + beginLibraryMutation, + endLibraryMutation, + runSerial, + StaleSessionError, +} from "./mutationQueue"; +import { settleOptimistic } from "./optimisticLikes"; + +export async function runTrackMutation( + trackId: string, + apply: (trackId: string, scope: AccountScope) => void, + call: (trackId: string, signal: AbortSignal) => Promise, +): Promise { + const scope = captureScope(); + if (!scope) throw new StaleSessionError(); + + beginLibraryMutation(); + apply(trackId, scope); + + let ok = false; + try { + const result = await runSerial(trackId, scope, (signal) => + call(trackId, signal), + ); + ok = true; + return result; + } finally { + settleOptimistic(trackId, ok, scope); + endLibraryMutation(); + } +} diff --git a/src/features/library/hooks/likes/useLikeTrack.ts b/src/features/library/hooks/likes/useLikeTrack.ts new file mode 100644 index 00000000..6c192197 --- /dev/null +++ b/src/features/library/hooks/likes/useLikeTrack.ts @@ -0,0 +1,10 @@ +import { api } from "@/shared/api"; +import { applyOptimisticLike } from "./optimisticLikes"; +import { useTrackMutation } from "./useTrackMutation"; + +const likeTrack = (trackId: string, signal: AbortSignal) => + api.likeTrack(trackId, { signal }); + +export function useLikeTrack() { + return useTrackMutation(applyOptimisticLike, likeTrack); +} diff --git a/src/features/library/hooks/likes/useTrackMutation.ts b/src/features/library/hooks/likes/useTrackMutation.ts new file mode 100644 index 00000000..30bcf4f1 --- /dev/null +++ b/src/features/library/hooks/likes/useTrackMutation.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; +import type { AccountScope } from "./accountScope"; +import { + StaleSessionError, + trackIdFrom, + type MutateOptions, + type TrackMutationInput, +} from "./mutationQueue"; +import { runTrackMutation } from "./runTrackMutation"; + +export function useTrackMutation( + apply: (trackId: string, scope: AccountScope) => void, + call: (trackId: string, signal: AbortSignal) => Promise, +) { + const [pendingCount, setPendingCount] = useState(0); + + const mutateAsync = useCallback( + async (input: TrackMutationInput) => { + setPendingCount((n) => n + 1); + try { + return await runTrackMutation(trackIdFrom(input), apply, call); + } finally { + setPendingCount((n) => n - 1); + } + }, + [apply, call], + ); + + const mutate = useCallback( + (input: TrackMutationInput, options?: MutateOptions) => { + void mutateAsync(input) + .then( + (value) => options?.onSuccess?.(value), + (error) => { + if (!(error instanceof StaleSessionError)) options?.onError?.(error); + }, + ) + .finally(() => options?.onSettled?.()); + }, + [mutateAsync], + ); + + return { mutate, mutateAsync, isPending: pendingCount > 0 }; +} diff --git a/src/features/library/hooks/likes/useUnlikeTrack.ts b/src/features/library/hooks/likes/useUnlikeTrack.ts new file mode 100644 index 00000000..f48dc560 --- /dev/null +++ b/src/features/library/hooks/likes/useUnlikeTrack.ts @@ -0,0 +1,10 @@ +import { api } from "@/shared/api"; +import { applyOptimisticUnlike } from "./optimisticLikes"; +import { useTrackMutation } from "./useTrackMutation"; + +const unlikeTrack = (trackId: string, signal: AbortSignal) => + api.unlikeTrack(trackId, { signal }); + +export function useUnlikeTrack() { + return useTrackMutation(applyOptimisticUnlike, unlikeTrack); +} diff --git a/src/features/library/hooks/useIsTrackLiked.ts b/src/features/library/hooks/useIsTrackLiked.ts index 044620af..ef403e54 100644 --- a/src/features/library/hooks/useIsTrackLiked.ts +++ b/src/features/library/hooks/useIsTrackLiked.ts @@ -1,10 +1,18 @@ -import { useMemo } from "react"; import { useLikedTracks } from "./useLikedTracks"; +const idSets = new WeakMap>(); + +function likedIdsOf(tracks: { id: string }[]): Set { + let set = idSets.get(tracks); + if (!set) { + set = new Set(tracks.map((t) => t.id)); + idSets.set(tracks, set); + } + return set; +} + export function useIsTrackLiked(trackId: string | undefined): boolean { const { data } = useLikedTracks(); - return useMemo(() => { - if (!trackId || !data?.tracks) return false; - return data.tracks.some((t) => t.id === trackId); - }, [trackId, data]); + if (!trackId || !data?.tracks) return false; + return likedIdsOf(data.tracks).has(trackId); } diff --git a/src/features/library/hooks/useLibraryMutations.ts b/src/features/library/hooks/useLibraryMutations.ts index a4545708..f1bb603e 100644 --- a/src/features/library/hooks/useLibraryMutations.ts +++ b/src/features/library/hooks/useLibraryMutations.ts @@ -1,13 +1,8 @@ import { useCallback, useState } from "react"; import { api } from "@/shared/api"; -import { notifyLibraryChanged, evictPlaylistFromCache } from "./usePlaylists"; -import { applyOptimisticLike, applyOptimisticUnlike } from "./useLikedTracks"; - -type Options = { - onSuccess?: (...args: any[]) => void; - onError?: (error: unknown) => void; - onSettled?: () => void; -}; +import { evictPlaylistFromCache } from "./usePlaylists"; +import { notifyLibraryChanged } from "./usePlaylists.ts"; +import type { MutateOptions } from "./likes/mutationQueue"; function useMutation(action: (input: T) => Promise) { const [isPending, setIsPending] = useState(false); @@ -25,7 +20,7 @@ function useMutation(action: (input: T) => Promise) { [action], ); const mutate = useCallback( - (input: T, options?: Options) => { + (input: T, options?: MutateOptions) => { void mutateAsync(input) .then((value) => options?.onSuccess?.(value)) .catch((error) => options?.onError?.(error)) @@ -132,71 +127,3 @@ export function useRemoveExternalItem() { }, ); } - -type TrackMutationInput = string | { trackId: string; [key: string]: unknown }; -const trackIdFrom = (input: TrackMutationInput) => - typeof input === "string" ? input : input.trackId; - -export function useLikeTrack() { - const [isPending, setIsPending] = useState(false); - - const mutateAsync = useCallback(async (input: TrackMutationInput) => { - const trackId = trackIdFrom(input); - setIsPending(true); - const rollback = applyOptimisticLike(trackId); - try { - const result = await api.likeTrack(trackId); - notifyLibraryChanged(); - return result; - } catch (err) { - rollback(); - throw err; - } finally { - setIsPending(false); - } - }, []); - - const mutate = useCallback( - (input: TrackMutationInput, options?: Options) => { - void mutateAsync(input) - .then((value) => options?.onSuccess?.(value)) - .catch((error) => options?.onError?.(error)) - .finally(() => options?.onSettled?.()); - }, - [mutateAsync], - ); - - return { mutate, mutateAsync, isPending }; -} - -export function useUnlikeTrack() { - const [isPending, setIsPending] = useState(false); - - const mutateAsync = useCallback(async (input: TrackMutationInput) => { - const trackId = trackIdFrom(input); - setIsPending(true); - const rollback = applyOptimisticUnlike(trackId); - try { - const result = await api.unlikeTrack(trackId); - notifyLibraryChanged(); - return result; - } catch (err) { - rollback(); - throw err; - } finally { - setIsPending(false); - } - }, []); - - const mutate = useCallback( - (input: TrackMutationInput, options?: Options) => { - void mutateAsync(input) - .then((value) => options?.onSuccess?.(value)) - .catch((error) => options?.onError?.(error)) - .finally(() => options?.onSettled?.()); - }, - [mutateAsync], - ); - - return { mutate, mutateAsync, isPending }; -} diff --git a/src/features/library/hooks/useLikedTracks.test.tsx b/src/features/library/hooks/useLikedTracks.test.tsx index 0a93960e..e9d57a34 100644 --- a/src/features/library/hooks/useLikedTracks.test.tsx +++ b/src/features/library/hooks/useLikedTracks.test.tsx @@ -1,109 +1,223 @@ // @vitest-environment jsdom import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createRoot, Root } from "react-dom/client"; import { act } from "react"; -import { useLikedTracks, applyOptimisticLike } from "./useLikedTracks"; +import { createRoot, type Root } from "react-dom/client"; import { api } from "@/shared/api"; +import { captureScope } from "./likes/accountScope"; +import { applyOptimisticLike, settleOptimistic } from "./likes/optimisticLikes"; +import { useLikedTracks } from "./useLikedTracks"; + +const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +type StubTrack = { id: string; title?: string; artists?: string }; +type LikedPage = Awaited>; + +const auth = vi.hoisted(() => ({ + session: null as { accessToken: string; user: { id: string } } | null, +})); vi.mock("@/shared/api", () => ({ api: { listLikedTracks: vi.fn(), }, - getAuthSession: () => ({ accessToken: "test-token", user: { id: "u1" } }), - toClientTrack: (track: any) => ({ + getAuthSession: () => auth.session, + toClientTrack: (track: StubTrack) => ({ id: track.id, - title: track.title, - artists: track.artists || "", - coverUrl: track.coverUrl || "", - durationMs: track.durationMs || 0, - playCount: track.playCount || 0, + title: track.title ?? "", + artists: track.artists ?? "", + coverUrl: "", + durationMs: 0, + playCount: 0, }), })); +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +const listLiked = vi.mocked(api.listLikedTracks); + +const page = (...ids: string[]) => + ({ + items: ids.map((id) => ({ track: { id, title: `Song ${id}` } })), + nextCursor: null, + }) as unknown as LikedPage; + +const signIn = (id: string) => { + auth.session = { accessToken: `token-${id}`, user: { id } }; +}; + +let hookResult: ReturnType | undefined; + +function TestComponent() { + hookResult = useLikedTracks(); + return
loaded: {(!hookResult.isLoading).toString()}
; +} + +const trackIds = () => hookResult?.data.tracks.map((t) => t.id); +const settle = () => act(async () => void (await flush())); +const requestRefetch = () => + act(async () => void window.dispatchEvent(new Event("library:changed"))); +const switchTo = (id: string) => + act(async () => { + signIn(id); + window.dispatchEvent(new Event("auth:changed")); + }); + +function optimisticLike(id: string) { + const scope = captureScope(); + if (!scope) throw new Error("expected a signed-in scope"); + act(() => applyOptimisticLike(id, scope)); + return scope; +} + describe("useLikedTracks", () => { let container: HTMLDivElement; let root: Root; + const mount = async () => { + await act(async () => { + root.render(); + }); + await settle(); + }; + beforeEach(() => { + vi.resetAllMocks(); + hookResult = undefined; + signIn("u1"); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); - vi.clearAllMocks(); }); - afterEach(() => { + afterEach(async () => { act(() => { root.unmount(); }); document.body.removeChild(container); + auth.session = null; + window.dispatchEvent(new Event("auth:changed")); + await settle(); }); it("does not flip isLoading to true when library:changed event is dispatched after initial load", async () => { - (api.listLikedTracks as any).mockResolvedValue({ - items: [ - { - track: { id: "track-1", title: "Song 1", artists: "Artist 1" }, - }, - ], - nextCursor: null, - }); - - let hookResult: ReturnType | undefined; + listLiked.mockResolvedValue(page("track-1")); + await mount(); - function TestComponent() { - hookResult = useLikedTracks(); - return
loaded: {(!hookResult.isLoading).toString()}
; - } + expect(hookResult?.isLoading).toBe(false); + expect(hookResult?.data.tracks).toHaveLength(1); - await act(async () => { - root.render(); - }); + optimisticLike("track-2"); + expect(hookResult?.data.tracks).toHaveLength(2); + expect(hookResult?.isLoading).toBe(false); - await act(async () => { - await Promise.resolve(); - }); + const background = deferred(); + listLiked.mockReturnValue(background.promise); + await requestRefetch(); expect(hookResult?.isLoading).toBe(false); - expect(hookResult?.data.tracks).toHaveLength(1); - // Apply optimistic like - act(() => { - applyOptimisticLike("track-2"); - }); - expect(hookResult?.data.tracks).toHaveLength(2); + background.resolve(page("track-1", "track-2")); + await settle(); + expect(hookResult?.isLoading).toBe(false); + expect(hookResult?.data.tracks).toHaveLength(2); + }); - // Dispatch library:changed event to simulate background update after liking - let backgroundPromiseResolve: (value: any) => void; - const backgroundPromise = new Promise((resolve) => { - backgroundPromiseResolve = resolve; - }); + it("keeps an optimistic like whose request is still in flight when a refetch comes back without it", async () => { + listLiked.mockResolvedValue(page("track-1")); + await mount(); - (api.listLikedTracks as any).mockImplementation(() => backgroundPromise); + optimisticLike("track-2"); + await requestRefetch(); + await settle(); - await act(async () => { - window.dispatchEvent(new Event("library:changed")); + expect(trackIds()).toEqual(["track-2", "track-1"]); + }); + + it("drops a settled optimistic like only after a refetch that started once it settled", async () => { + listLiked.mockResolvedValue(page("track-1")); + await mount(); + const scope = optimisticLike("track-2"); + + const early = deferred(); + listLiked.mockReturnValueOnce(early.promise); + await requestRefetch(); + act(() => settleOptimistic("track-2", true, scope)); + early.resolve(page("track-1")); + await settle(); + + expect(trackIds()).toEqual(["track-2", "track-1"]); + + listLiked.mockResolvedValue(page("track-1", "track-2")); + await requestRefetch(); + await settle(); + + expect(trackIds()).toEqual(["track-1", "track-2"]); + }); + + describe("across account changes", () => { + it("hides the previous account's liked tracks as soon as another account signs in", async () => { + listLiked.mockResolvedValue(page("a-1")); + await mount(); + expect(trackIds()).toEqual(["a-1"]); + + const forB = deferred(); + listLiked.mockReturnValue(forB.promise); + await switchTo("u2"); + expect(trackIds()).toEqual([]); + + forB.resolve(page("b-1")); + await settle(); + expect(trackIds()).toEqual(["b-1"]); }); - // isLoading MUST REMAIN FALSE during background refetch - expect(hookResult?.isLoading).toBe(false); + it("discards a response that arrives for the previous account and loads the new account instead", async () => { + const forA = deferred(); + const forB = deferred(); + listLiked.mockReturnValueOnce(forA.promise).mockReturnValueOnce(forB.promise); + await mount(); - // Resolve background refetch - await act(async () => { - backgroundPromiseResolve!({ - items: [ - { track: { id: "track-1", title: "Song 1", artists: "Artist 1" } }, - { track: { id: "track-2", title: "Song 2", artists: "Artist 2" } }, - ], - nextCursor: null, - }); - await Promise.resolve(); + await switchTo("u2"); + forA.resolve(page("a-1")); + await settle(); + + expect(trackIds()).toEqual([]); + expect(listLiked).toHaveBeenCalledTimes(2); + + forB.resolve(page("b-1")); + await settle(); + expect(trackIds()).toEqual(["b-1"]); }); - expect(hookResult?.isLoading).toBe(false); - expect(hookResult?.data.tracks).toHaveLength(2); + it("still loads the new account when no auth event was delivered", async () => { + const forA = deferred(); + const forB = deferred(); + listLiked.mockReturnValueOnce(forA.promise).mockReturnValueOnce(forB.promise); + await mount(); + + signIn("u2"); + forA.resolve(page("a-1")); + await settle(); + + expect(trackIds()).toEqual([]); + expect(listLiked).toHaveBeenCalledTimes(2); + + forB.resolve(page("b-1")); + await settle(); + expect(trackIds()).toEqual(["b-1"]); + }); }); }); diff --git a/src/features/library/hooks/useLikedTracks.ts b/src/features/library/hooks/useLikedTracks.ts index 1960df7a..22708d1e 100644 --- a/src/features/library/hooks/useLikedTracks.ts +++ b/src/features/library/hooks/useLikedTracks.ts @@ -1,47 +1,29 @@ import { useEffect, useSyncExternalStore } from "react"; -import { api, getAuthSession, toClientTrack } from "@/shared/api"; -import type { Track } from "@/shared/types"; +import { getAuthSession } from "@/shared/api"; +import { + captureScope, + isScopeActive, + onAccountChange, + syncAccountScope, +} from "./likes/accountScope"; +import { buildLikedView, type LikedData } from "./likes/buildLikedView"; +import { fetchLikedTracks } from "./likes/fetchLikedTracks"; +import { + currentSettleSeq, + onOverridesChange, + reconcileOverrides, +} from "./likes/optimisticLikes"; -type LikedData = { tracks: Track[]; total: number }; const EMPTY: LikedData = { tracks: [], total: 0 }; let cache: LikedData = EMPTY; let loaded = false; let request: Promise | null = null; +let refetchQueued = false; const listeners = new Set<() => void>(); let view = { data: cache, isLoading: true }; -const optimisticOverrides = new Map(); - -function buildView(isLoading: boolean): { - data: LikedData; - isLoading: boolean; -} { - if (optimisticOverrides.size === 0) return { data: cache, isLoading }; - - let tracks = cache.tracks.filter( - (t) => optimisticOverrides.get(t.id) !== false, - ); - for (const [id, liked] of optimisticOverrides) { - if (liked && !tracks.some((t) => t.id === id)) { - tracks = [ - { - id, - title: "", - artists: "", - artistId: undefined, - coverUrl: "", - durationMs: 0, - playCount: 0, - } as Track, - ...tracks, - ]; - } - } - return { data: { tracks, total: tracks.length }, isLoading }; -} - function emit(isLoading = false) { - view = buildView(isLoading); + view = buildLikedView(cache, isLoading); listeners.forEach((l) => l()); } @@ -54,12 +36,23 @@ function snapshot() { return view; } +onOverridesChange(() => emit(false)); + +onAccountChange(() => { + cache = EMPTY; + loaded = false; + emit(false); +}); + const SERVER_SNAPSHOT = { data: EMPTY, isLoading: true }; function load(force = false): Promise { - if (request) return request; - const session = getAuthSession(); - if (!session?.accessToken) { + if (request) { + if (force) refetchQueued = true; + return request; + } + const scope = captureScope(); + if (!getAuthSession()?.accessToken || !scope) { cache = EMPTY; loaded = false; emit(false); @@ -69,63 +62,44 @@ function load(force = false): Promise { if (!loaded) emit(true); + const startSeq = currentSettleSeq(); + request = (async () => { - const tracks: Track[] = []; - let cursor: string | undefined; - do { - const page = await api.listLikedTracks(100, cursor); - tracks.push( - ...page.items.map(({ track, likedAt }) => ({ - ...toClientTrack(track), - addedAt: likedAt, - })), - ); - cursor = page.nextCursor ?? undefined; - } while (cursor); + const tracks = await fetchLikedTracks(scope); + if (!tracks) { + refetchQueued = true; + return; + } cache = { tracks, total: tracks.length }; loaded = true; - optimisticOverrides.clear(); + reconcileOverrides(startSeq); })() .catch(() => { + if (!isScopeActive(scope)) { + refetchQueued = true; + return; + } if (!loaded) cache = EMPTY; loaded = true; }) .finally(() => { request = null; emit(false); + if (refetchQueued) { + refetchQueued = false; + void load(true); + } }); return request; } -export function applyOptimisticLike(trackId: string): () => void { - const prev = optimisticOverrides.get(trackId); - optimisticOverrides.set(trackId, true); - emit(false); - return () => { - if (prev === undefined) optimisticOverrides.delete(trackId); - else optimisticOverrides.set(trackId, prev); - emit(false); - }; -} - -export function applyOptimisticUnlike(trackId: string): () => void { - const prev = optimisticOverrides.get(trackId); - optimisticOverrides.set(trackId, false); - emit(false); - return () => { - if (prev === undefined) optimisticOverrides.delete(trackId); - else optimisticOverrides.set(trackId, prev); - emit(false); - }; -} - if (typeof window !== "undefined") { const handleRefresh = () => { + syncAccountScope(); const session = getAuthSession(); if (!session?.accessToken) { cache = EMPTY; loaded = false; - optimisticOverrides.clear(); emit(false); } else { void load(true); diff --git a/src/features/overlay/hooks/useOverlaySync.ts b/src/features/overlay/hooks/useOverlaySync.ts index 5407e3de..285cfec2 100644 --- a/src/features/overlay/hooks/useOverlaySync.ts +++ b/src/features/overlay/hooks/useOverlaySync.ts @@ -3,9 +3,9 @@ import { playerEngine } from "@/features/player/engine/playerEngine"; import { usePlayerStore } from "@/features/player/store/playerStore"; import { useIsTrackLiked, - useLikeTrack, - useUnlikeTrack, } from "@/features/library/hooks"; +import { useLikeTrack } from "@/features/library/hooks/likes/useLikeTrack"; +import { useUnlikeTrack } from "@/features/library/hooks/likes/useUnlikeTrack"; import { useCoverSrc } from "@/features/covers"; import { getAuthSession } from "@/shared/api"; import { useAuthStore } from "@/features/auth/store/authStore"; diff --git a/src/features/player/engine/playerEngine.ts b/src/features/player/engine/playerEngine.ts index 6f0b35f2..7508a3c6 100644 --- a/src/features/player/engine/playerEngine.ts +++ b/src/features/player/engine/playerEngine.ts @@ -1566,8 +1566,7 @@ class PlayerEngine { ); } catch (error) { if (!isCurrent()) return; - if (!storedCandidate) - this.showNoLyrics(trackId, "Failed to load lyrics."); + if (!storedCandidate) this.showNoLyrics(trackId, "Failed to load lyrics."); log( "red", "lyrics", diff --git a/src/features/player/hooks/useSongMenuItems.tsx b/src/features/player/hooks/useSongMenuItems.tsx index 17572063..a80d1069 100644 --- a/src/features/player/hooks/useSongMenuItems.tsx +++ b/src/features/player/hooks/useSongMenuItems.tsx @@ -13,11 +13,11 @@ import type { DropdownMenuItem } from "@/shared/ui/DropdownMenu"; import { playerEngine } from "../engine/playerEngine"; import { usePlaylistsList, - useLikeTrack, - useUnlikeTrack, useIsTrackLiked, useModalStore, } from "@/features/library"; +import { useLikeTrack } from "@/features/library/hooks/likes/useLikeTrack"; +import { useUnlikeTrack } from "@/features/library/hooks/likes/useUnlikeTrack"; import { useTranslation } from "@/languages"; import { showToast } from "@/shared/ui"; import { buildShareUrl } from "@/shared/utils/share"; diff --git a/src/features/player/ui/FullscreenTrackInfo.tsx b/src/features/player/ui/FullscreenTrackInfo.tsx index 8e3d5289..793d5b6d 100644 --- a/src/features/player/ui/FullscreenTrackInfo.tsx +++ b/src/features/player/ui/FullscreenTrackInfo.tsx @@ -2,9 +2,9 @@ import { useCallback, useRef } from "react"; import { useLocation } from "react-router-dom"; import { useIsTrackLiked, - useLikeTrack, - useUnlikeTrack, } from "@/features/library/hooks"; +import { useLikeTrack } from "@/features/library/hooks/likes/useLikeTrack"; +import { useUnlikeTrack } from "@/features/library/hooks/likes/useUnlikeTrack"; import { ArtistLink } from "@/features/artist"; import { isLocalTrackId } from "@/shared/media/local"; import { useTranslation } from "@/languages"; diff --git a/src/features/player/ui/MiniPlayer.tsx b/src/features/player/ui/MiniPlayer.tsx index 03988567..25968bd8 100644 --- a/src/features/player/ui/MiniPlayer.tsx +++ b/src/features/player/ui/MiniPlayer.tsx @@ -25,9 +25,9 @@ import { isRadioContext, nextRepeatMode } from "../engine/radioMode"; import { useTranslation } from "@/languages"; import { useIsTrackLiked, - useLikeTrack, - useUnlikeTrack, } from "@/features/library/hooks"; +import { useLikeTrack } from "@/features/library/hooks/likes/useLikeTrack"; +import { useUnlikeTrack } from "@/features/library/hooks/likes/useUnlikeTrack"; import { usePlayerStore } from "../store/playerStore"; import { toVolumeGain, toVolumeLevel } from "../engine/volume"; import { VolumeColumn } from "./VolumeColumn"; diff --git a/src/features/player/ui/SongCard.tsx b/src/features/player/ui/SongCard.tsx index f4fda155..516c0028 100644 --- a/src/features/player/ui/SongCard.tsx +++ b/src/features/player/ui/SongCard.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState, useCallback } from "react"; import { More2Fill, RepeatFill } from "@mingcute/react"; import DropdownMenu from "@/shared/ui/DropdownMenu"; import type { DropdownMenuItem } from "@/shared/ui/DropdownMenu"; @@ -9,6 +9,11 @@ import ExplicitBadge from "@/shared/ui/ExplicitBadge"; import CoverImage from "@/features/covers/ui/CoverImage"; import { isCoverReady } from "@/features/covers/lib/coverArt"; import type { TrackArtist } from "@/shared/types"; +import { LuHeart } from "react-icons/lu"; +import Button from "@/shared/ui/Button"; +import { useIsTrackLiked } from "@/features/library"; +import { useLikeTrack } from "@/features/library/hooks/likes/useLikeTrack"; +import { useUnlikeTrack } from "@/features/library/hooks/likes/useUnlikeTrack"; export interface SongCardProps { id?: string; @@ -24,6 +29,8 @@ export interface SongCardProps { duration?: string; releaseDate?: string; explicit?: boolean; + isArtist?: boolean; + isLikeable?: boolean; imageShape?: "square" | "circle"; trackNumber?: number; repostedBy?: string; @@ -44,6 +51,7 @@ export interface SongCardProps { } function SongCard({ + id, title, artists, artistId, @@ -56,6 +64,8 @@ function SongCard({ duration, releaseDate, explicit = false, + isArtist = false, + isLikeable = true, imageShape = "square", trackNumber, repostedBy, @@ -123,6 +133,20 @@ function SongCard({ }, 200); }; + const liked = useIsTrackLiked(id); + const unlikeMutation = useUnlikeTrack(); + const likeMutation = useLikeTrack(); + + const handleLike = useCallback(() => { + if (!id) return; + + if (liked) { + unlikeMutation.mutate(id); + } else { + likeMutation.mutate(id); + } + }, [id, liked, unlikeMutation, likeMutation]); + return (
)} {trailingAction} + {!isArtist && isLikeable && id && ( + + )}
); diff --git a/src/features/player/ui/SongCardWithMenu.tsx b/src/features/player/ui/SongCardWithMenu.tsx index 3478c4c7..75442dec 100644 --- a/src/features/player/ui/SongCardWithMenu.tsx +++ b/src/features/player/ui/SongCardWithMenu.tsx @@ -23,6 +23,7 @@ export interface SongCardWithMenuProps { releaseDate?: string; explicit?: boolean; hideArtists?: boolean; + isArtist?: boolean; imageShape?: "square" | "circle"; trackNumber?: number; repostedBy?: string; @@ -57,6 +58,7 @@ function SongCardWithMenu({ releaseDate, explicit, hideArtists, + isArtist, imageShape, trackNumber, repostedBy, @@ -161,6 +163,9 @@ function SongCardWithMenu({ if (!isOpen) setMenuPosition(null); }, []); + const likeable = + (!searchType || searchType === "track") && !isLocalTrackId(id); + return ( (`/v1/me/library/tracks?${params}`); }, - async likeTrack(id: string): Promise { + async likeTrack(id: string, options?: {signal?: AbortSignal}): Promise { if (isLocalTrackId(id)) throw new Error("Local tracks stay on this device"); const track = await clientCatalog.resolveSnapshot(id); + options?.signal?.throwIfAborted(); return request(`/v1/me/library/tracks/${encodeURIComponent(id)}`, { method: "PUT", + signal: options?.signal, ...(track ? { body: JSON.stringify({ track, trackSnapshot: track }) } : {}), }); }, - unlikeTrack(id: string): Promise { + unlikeTrack(id: string, options?: {signal?: AbortSignal}): Promise { + options?.signal?.throwIfAborted(); return request(`/v1/me/library/tracks/${encodeURIComponent(id)}`, { method: "DELETE", + signal: options?.signal, }); },