diff --git a/CHANGELOG.md b/CHANGELOG.md index 425df06..7be0f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ easy to spot when bumping. ## [Unreleased] +## [43.0.0] - 2026-11-12 + +### Breaking + +- `PlayerPanel` no longer accepts the already-ignored `onClose` prop; remove it. Closing is built in (see Added). +- `ParentChange.to` (app-kit-ui, `onParentChanged`) is now `GenreUuid | null`; `null` means the genre was made a root. Handle it before using `to`. +- `fetchWrapper` / `useFetchWrapper().fetch` / `usePlaybackFetch` now reject with the typed `AppError` on a failed GET or HEAD after reporting it to the global handler, instead of resolving `null`. Direct `await fetch(...)` callers on reads must handle the rejection (query functions need no change). + +### Added + +- `usePlayer().stop()` stops playback and unloads the current track (`playerTrackObject` becomes `null`); `PlayerPanel` shows a **Close player** header button that calls it. +- Parent popup (`ChangeParentPopup`): a **Make root** button, shown when the genre has a parent, with an inline confirm step. +- `isRetryableError` (transport) and `AUTH_DETAIL_ERROR_CODES`: one classification of which failures a retry can fix. + +### Changed + +- `GenreDetailPanel`: the Ancestors section now ends on the selected genre (non-clickable) and draws every ancestor at the Family section's size; a long parent label in Family wraps instead of being truncated. Requires `@behindthemusictree/genre-tree-view` 10.0.0 (catalog bumped). + +### Fixed + +- A rejected token (`AuthRequired`, BAC4010) no longer re-sends the request up to three times, re-raising the popup and clearing the session each time: `resourceRetry` skips `AuthRequired`, not-found, forbidden, bad-request, client and auth-detail errors. + ## [42.0.2] - 2026-11-12 ### Fixed diff --git a/packages/app-kit-ui/package.json b/packages/app-kit-ui/package.json index 7af32c8..2e0d460 100644 --- a/packages/app-kit-ui/package.json +++ b/packages/app-kit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/app-kit-ui", - "version": "42.0.2", + "version": "43.0.0", "description": "Styled BehindTheMusicTree React components (popups, auth callback, genre tree) built on the headless @behindthemusictree/app-kit", "repository": { "type": "git", @@ -47,7 +47,7 @@ "test": "vitest run --coverage" }, "peerDependencies": { - "@behindthemusictree/app-kit": "^42.0.2", + "@behindthemusictree/app-kit": "^43.0.0", "@behindthemusictree/ui": "^0.21.2", "@tanstack/react-query": ">=5.0.0", "next": ">=14.0.0", diff --git a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.test.tsx b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.test.tsx index f1d05cb..be1227e 100644 --- a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.test.tsx +++ b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.test.tsx @@ -122,6 +122,45 @@ describe("ChangeParentPopup", () => { }); }); + describe("Make root", () => { + it("is hidden for a root genre", () => { + renderPopup({ genrePlaylist: blues }); + expect(screen.queryByRole("button", { name: "Make root" })).not.toBeInTheDocument(); + }); + + it("asks for confirmation, then sends a null parent and reports to: null", () => { + const onParentChanged = vi.fn(); + const { onClose } = renderPopup({ onParentChanged }); + + fireEvent.click(screen.getByRole("button", { name: "Make root" })); + expect(screen.getByText(/Jazz and its descendants will no longer be under Rock/)).toBeInTheDocument(); + expect(mutateMock).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect(mutateMock).toHaveBeenCalledWith( + { uuid: "c-jazz", data: { parent: null } }, + { onSuccess: expect.any(Function) }, + ); + mutateMock.mock.calls[0][1].onSuccess(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onParentChanged).toHaveBeenCalledWith({ genre: "c-jazz", from: "c-rock", to: null }); + }); + + it("goes back without mutating", () => { + renderPopup(); + fireEvent.click(screen.getByRole("button", { name: "Make root" })); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByRole("button", { name: "Make root" })).toBeInTheDocument(); + expect(mutateMock).not.toHaveBeenCalled(); + }); + + it("shows a 400 message in the alert", () => { + mockMutation({ formErrors: [{ field: "parent", message: "Cannot make root" }] }); + renderPopup(); + expect(screen.getByRole("alert")).toHaveTextContent("Cannot make root"); + }); + }); + it("marks the search busy while the update is pending", () => { mockMutation({ isPending: true }); renderPopup(); diff --git a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx index b8710c5..8213544 100644 --- a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx +++ b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { CriteriaPlaylistSimple, @@ -10,10 +10,12 @@ import { useUpdateGenre, } from "@behindthemusictree/app-kit/genre-tree"; import type { Scope } from "@behindthemusictree/app-kit/transport"; +import { Button } from "@behindthemusictree/ui"; import BasePopup from "../popup/BasePopup"; +import { PopupButtons } from "../popup/PopupButtons"; import GenreSearch from "./GenreSearch"; -export type ParentChange = { genre: GenreUuid; from: GenreUuid | null; to: GenreUuid }; +export type ParentChange = { genre: GenreUuid; from: GenreUuid | null; to: GenreUuid | null }; type ChangeParentPopupProps = { genrePlaylist: ReparentCandidate; @@ -38,23 +40,27 @@ export default function ChangeParentPopup({ [genrePlaylists, genrePlaylist.uuid], ); - const handleSelect = (newParent: ReparentCandidate) => { + const [confirmingRoot, setConfirmingRoot] = useState(false); + + const handleMove = (to: GenreUuid | null) => { const genre = genrePlaylist.criteria.uuid; const parentPlaylistUuid = genrePlaylist.parent?.uuid; const from = parentPlaylistUuid ? (genrePlaylists.find(({ uuid }) => uuid === parentPlaylistUuid)?.criteria?.uuid ?? null) : null; mutate( - { uuid: genre, data: { parent: newParent.criteria.uuid } }, + { uuid: genre, data: { parent: to } }, { onSuccess: () => { onClose(); - onParentChanged?.({ genre, from, to: newParent.criteria.uuid }); + onParentChanged?.({ genre, from, to }); }, }, ); }; + const handleSelect = (newParent: ReparentCandidate) => handleMove(newParent.criteria.uuid); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); @@ -68,6 +74,28 @@ export default function ChangeParentPopup({

Current parent: {genrePlaylist.parent?.name ?? "none"}

+ {genrePlaylist.parent && + (confirmingRoot ? ( +
+

+ {genrePlaylist.name} and its descendants will no longer be under {genrePlaylist.parent.name}. +

+ + + + +
+ ) : ( +
+ +
+ ))}
({ onSelectNode={selectGenre} /> ); - const ancestors = ; + const ancestors = ( + + ); return { name: detail.name, diff --git a/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx b/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx index 278f6d7..95d1174 100644 --- a/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx @@ -81,7 +81,7 @@ export type GenreTreeViewProps = handleGenreCreationAction: (parent: CriteriaMinimum | null) => void; handleGenreRenameAction: (genre: CriteriaMinimum) => void; getBackendBaseUrl: () => string; - /** Called after the Parent popup moved a genre; `from` and `to` are genre (criteria) uuids, `from` null for a root genre. */ + /** Called after the Parent popup moved a genre; `from` and `to` are genre (criteria) uuids, `from` null for a root genre, `to` null when the genre was made a root. */ onParentChanged?: (change: ParentChange) => void; additionalActions?: (node: GenreTreeNode) => GenreTreeAction[]; /** Controlled view mode. When provided, the internal Stacked/Wheel toggle is not rendered — the consumer owns that UI. */ diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx index 3037147..110f6d2 100644 --- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx +++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.test.tsx @@ -66,6 +66,15 @@ describe("PlayerPanel", () => { expect(screen.queryByRole("button", { name: "Extend song list panel" })).not.toBeInTheDocument(); }); + it("stops playback from the close button", () => { + const stop = vi.fn(); + setup(true); + usePlayerMock.mockReturnValue({ playerTrackObject: { track: { title: "Song A" } }, stop }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Close player" })); + expect(stop).toHaveBeenCalledTimes(1); + }); + it("hides the song list from the From header", () => { setup(true); render(); @@ -106,7 +115,7 @@ describe("PlayerPanel", () => { expect(onExpandedChange).toHaveBeenCalledWith(false); }); - it("minimizes to video-only without closing or unmounting the player", () => { + it("minimizes to video-only without unmounting the player", () => { setup(true); const onExpandedChange = vi.fn(); const { container, rerender } = render( @@ -124,7 +133,6 @@ describe("PlayerPanel", () => { expect(container.firstChild).toHaveClass("w-[var(--layout-right-w)]"); expect(screen.getByTestId("video")).toBeInTheDocument(); expect(screen.getByTestId("sidebar")).toHaveAttribute("data-collapsed", "true"); - expect(screen.queryByRole("button", { name: "Close player" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Show tracks" })); expect(toggleTrackListSidebar).toHaveBeenCalledTimes(2); diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx index 4774ca9..83a14cb 100644 --- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx +++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { X } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { usePlayer, @@ -11,7 +12,7 @@ import { type RenderArtistLink, type RenderSongLink, } from "@behindthemusictree/app-kit/genre-tree"; -import { PanelEdgeTab } from "@behindthemusictree/ui"; +import { IconButton, PanelEdgeTab } from "@behindthemusictree/ui"; import PlayerControls from "./PlayerControls"; import TrackListSidebar from "./track-list-sidebar/TrackListSidebar"; @@ -19,8 +20,6 @@ export interface PlayerPanelProps { className?: string; /** Hidden via CSS, never unmounted, so the video keeps playing while the host hides the panel. */ isVisible?: boolean; - /** @deprecated The player no longer closes; its header action minimizes to video-only mode. */ - onClose?: () => void; /** Controlled extended state; uncontrolled when omitted. */ expanded?: boolean; onExpandedChange?: (expanded: boolean) => void; @@ -46,7 +45,7 @@ export default function PlayerPanel({ renderArtistLink, renderSongLink, }: PlayerPanelProps) { - const { playerTrackObject, retryPlayback } = usePlayer(); + const { playerTrackObject, retryPlayback, stop } = usePlayer(); const { toggleTrackListSidebar, isTrackListSidebarVisible } = useTrackListSidebarVisibility(); const [lastLoadedTrackTitle, setLastLoadedTrackTitle] = useState(""); @@ -114,6 +113,11 @@ export default function PlayerPanel({ {loadedTrackTitle || lastLoadedTrackTitle} + {playerTrackObject && ( + + + )}
diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 0808f65..1e22ffe 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/app-kit", - "version": "42.0.2", + "version": "43.0.0", "description": "Headless transport, auth, popup state, player, and genre-tree data plumbing for BehindTheMusicTree React apps", "repository": { "type": "git", diff --git a/packages/app-kit/src/genre-tree/TrackListContext.test.tsx b/packages/app-kit/src/genre-tree/TrackListContext.test.tsx index dab13ae..a0c1be7 100644 --- a/packages/app-kit/src/genre-tree/TrackListContext.test.tsx +++ b/packages/app-kit/src/genre-tree/TrackListContext.test.tsx @@ -180,6 +180,14 @@ describe("TrackListContext", () => { }); }); + it("rejects with TracksFailureAlreadyReportedError when the request rejected with a reported AppError", async () => { + const { result } = renderHook(() => useTrackList(), { wrapper }); + fetchMock.mockRejectedValueOnce(new BackendError(ErrorCode.BACKEND_UNAUTHORIZED)); + await act(async () => { + await expect(result.current.playNewTrackListFromGenrePlaylist(genrePlaylist, "me")).rejects.toBeInstanceOf(TracksFailureAlreadyReportedError); + }); + }); + it("keeps a schema failure distinct from an already-reported failure when starting from a song", async () => { const { result } = renderHook(() => useTrackList(), { wrapper }); fetchMock.mockResolvedValueOnce(null); diff --git a/packages/app-kit/src/genre-tree/TrackListContext.tsx b/packages/app-kit/src/genre-tree/TrackListContext.tsx index 7453c19..353833b 100644 --- a/packages/app-kit/src/genre-tree/TrackListContext.tsx +++ b/packages/app-kit/src/genre-tree/TrackListContext.tsx @@ -14,7 +14,7 @@ import { genreMusicQueryParams, type GenreMusicSelection } from "./lib/genre-mus import { usePlaybackContext } from "../transport/PlaybackContext"; import { usePlaybackFetch } from "../transport/usePlaybackFetch"; import { usePlaybackQuery } from "../transport/lib/use-playback-query"; -import { isNotFoundError } from "../transport/app-errors/app-error"; +import { AppError, isNotFoundError } from "../transport/app-errors/app-error"; import { useSession } from "../auth/SessionContext"; import { PaginatedResponseSchema } from "../transport/lib/paginated-response"; import { parseWithLog } from "../transport/lib/parse-with-log"; @@ -232,7 +232,14 @@ export function TrackListProvider({ const endpoint = genrePlaylistEndpoints[scope].tracks(uuid); if (scope === "me" && (selection || artistUuid)) throw new Error("Genre selections require reference scope"); const params = { page, pageSize: TRACKS_PAGE_SIZE, ...(from && { from }), ...genreMusicQueryParams(selection), ...(artistUuid && { artist: artistUuid }) }; - const response = await (page === 1 ? fetchLatest : fetchBound)(endpoint, true, scope === "me", {}, params, false, !!from); + let response: unknown; + try { + response = await (page === 1 ? fetchLatest : fetchBound)(endpoint, true, scope === "me", {}, params, false, !!from); + } catch (error) { + // fetch-wrapper already reported a failed first-page read to the user; callers must not raise a second popup. + if (error instanceof AppError && !from) throw new TracksFailureAlreadyReportedError(); + throw error; + } if (response === null && !from) throw new TracksFailureAlreadyReportedError(); const parsed = parseWithLog(pageSchema, response, "fetchGenrePlaylistTracksPage"); return { diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts index b795ba1..132daa4 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts @@ -31,7 +31,7 @@ type RawPaginatedResponse = { * first page, then fetches the rest with at most FULL_LIST_CONCURRENCY requests in flight. */ const fetchAllPages = async (fetchPage: (page: number) => Promise): Promise => { - // fetch-wrapper resolves null (instead of throwing) when a handleError callback swallowed the failure. + // fetch-wrapper resolves null for an empty body (204) or a missing session. const fetchRequiredPage = async (page: number) => { const response = await fetchPage(page); if (response == null) throw new Error(`Paginated list page ${page} returned no body`); diff --git a/packages/app-kit/src/player/PlayerContext.test.tsx b/packages/app-kit/src/player/PlayerContext.test.tsx index ef19d5d..060eb7b 100644 --- a/packages/app-kit/src/player/PlayerContext.test.tsx +++ b/packages/app-kit/src/player/PlayerContext.test.tsx @@ -165,6 +165,23 @@ describe("PlayerProvider - loadTrackForPlayer (audio)", () => { expect(result.current.playbackConfirmedTrackId).toBe("audio-1"); }); + it("stop() pauses and unloads the current track", async () => { + loadTrackMock.mockResolvedValue(makeAudioTrack()); + const { result } = renderHook(() => usePlayer(), { wrapper }); + await act(async () => { + result.current.loadTrackForPlayer("audio-1"); + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => result.current.stop()); + + expect(audioInstances[0].pause).toHaveBeenCalled(); + expect(result.current.playerTrackObject).toBeNull(); + expect(result.current.isPlaying).toBe(false); + expect(result.current.playState).toBe(PlayStates.STOPPED); + }); + it("sets duration and currentTimeRef from audio events", async () => { loadTrackMock.mockResolvedValue(makeAudioTrack()); const { result } = renderHook(() => usePlayer(), { wrapper }); diff --git a/packages/app-kit/src/player/PlayerContext.tsx b/packages/app-kit/src/player/PlayerContext.tsx index 384a98d..6715f51 100644 --- a/packages/app-kit/src/player/PlayerContext.tsx +++ b/packages/app-kit/src/player/PlayerContext.tsx @@ -98,6 +98,8 @@ interface PlayerContextType { setPlayState: (state: PlayStates) => void; handlePlayPauseAction: () => void; loadTrackForPlayer: (trackId: string) => void; + /** Stops playback and unloads the current track, so `playerTrackObject` becomes `null`. */ + stop: () => void; isLoading: boolean; handleNextTrack: ( trackList: PlayerTrack[], @@ -215,6 +217,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { audioElementRef.current = audio; audio.addEventListener("loadedmetadata", () => { + if (audioElementRef.current !== audio) return; setDuration(audio.duration); }); @@ -357,6 +360,28 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { [loadTrack, loadAudioTrack, loadYoutubeTrack, playback.key], ); + const stop = useCallback(() => { + generation.current++; + terminal.current = true; + currentTrackIdRef.current = null; + currentTimeRef.current = 0; + if (audioElementRef.current) { + audioElementRef.current.pause(); + audioElementRef.current = null; + } + if (youtubePlayerRef.current) { + youtubePlayerRef.current.destroy(); + youtubePlayerRef.current = null; + } + videoContainerRef.current?.replaceChildren(); + setPlayerTrackObject(null); + setPlaybackConfirmedTrackId(null); + setDuration(0); + setIsLoading(false); + setPlayState(PlayStates.STOPPED); + setIsPlaying(false); + }, []); + const previousPlaybackKey = useRef(playback.key); useEffect(() => { if (previousPlaybackKey.current === playback.key) return; @@ -470,6 +495,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { setPlayState, handlePlayPauseAction, loadTrackForPlayer, + stop, isLoading, handleNextTrack, handlePreviousTrack, @@ -489,6 +515,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { isLoading, handlePlayPauseAction, loadTrackForPlayer, + stop, handleNextTrack, handlePreviousTrack, onTrackEnd, diff --git a/packages/app-kit/src/transport/app-errors/app-error-codes.ts b/packages/app-kit/src/transport/app-errors/app-error-codes.ts index 6d79b0b..78e13e5 100644 --- a/packages/app-kit/src/transport/app-errors/app-error-codes.ts +++ b/packages/app-kit/src/transport/app-errors/app-error-codes.ts @@ -56,3 +56,12 @@ export enum ErrorCode { // Backend invalid input BACKEND_INVALID_INPUT = "INP4220", } + +/** Backend auth failures that carry their own popup/flow: retrying cannot fix them. */ +export const AUTH_DETAIL_ERROR_CODES: readonly ErrorCode[] = [ + ErrorCode.BACKEND_GOOGLE_OAUTH_CODE_INVALID_OR_EXPIRED, + ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST, + ErrorCode.BACKEND_SPOTIFY_AUTHENTICATION_ERROR, + ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR, + ErrorCode.BACKEND_GOOGLE_OAUTH_MISCONFIGURED, +]; diff --git a/packages/app-kit/src/transport/app-errors/app-error.test.ts b/packages/app-kit/src/transport/app-errors/app-error.test.ts index 948f736..21e4d72 100644 --- a/packages/app-kit/src/transport/app-errors/app-error.test.ts +++ b/packages/app-kit/src/transport/app-errors/app-error.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { BackendError, BackendSpotifyUserNotAllowlistedError, InvalidInputError } from "./app-error"; -import { ErrorCode } from "./app-error-codes"; +import { + AuthRequired, + BackendError, + BackendSpotifyUserNotAllowlistedError, + InvalidInputError, + NetworkError, + ServiceError, + isRetryableError, +} from "./app-error"; +import { AUTH_DETAIL_ERROR_CODES, ErrorCode } from "./app-error-codes"; import { getSpotifyAllowlistMailtoHref } from "./app-error-messages"; describe("app-error classes", () => { @@ -41,3 +49,23 @@ describe("getSpotifyAllowlistMailtoHref", () => { expect(href).toContain("body="); }); }); + +describe("isRetryableError", () => { + it("never retries final errors", () => { + expect(isRetryableError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED))).toBe(false); + expect(isRetryableError(new InvalidInputError(ErrorCode.BACKEND_INVALID_INPUT, {}))).toBe(false); + expect(isRetryableError(new BackendError(ErrorCode.BACKEND_NOT_FOUND))).toBe(false); + expect(isRetryableError(new BackendError(ErrorCode.BACKEND_FORBIDDEN))).toBe(false); + expect(isRetryableError(new BackendError(ErrorCode.BACKEND_SPOTIFY_AUTHORIZATION_REQUIRED))).toBe(false); + for (const code of AUTH_DETAIL_ERROR_CODES) { + expect(isRetryableError(new BackendError(code))).toBe(false); + } + }); + + it("retries transient failures", () => { + expect(isRetryableError(new NetworkError(ErrorCode.NETWORK_ERROR))).toBe(true); + expect(isRetryableError(new ServiceError(ErrorCode.SERVICE_INTERNAL_ERROR))).toBe(true); + expect(isRetryableError(new BackendError(ErrorCode.BACKEND_BAD_REQUEST))).toBe(true); + expect(isRetryableError(new Error("parse"))).toBe(true); + }); +}); diff --git a/packages/app-kit/src/transport/app-errors/app-error.ts b/packages/app-kit/src/transport/app-errors/app-error.ts index 401011a..128c680 100644 --- a/packages/app-kit/src/transport/app-errors/app-error.ts +++ b/packages/app-kit/src/transport/app-errors/app-error.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from "./app-error-codes"; +import { AUTH_DETAIL_ERROR_CODES, ErrorCode } from "./app-error-codes"; import { ErrorMessages } from "./app-error-messages"; export class AppError extends Error { @@ -88,6 +88,22 @@ export class ServiceError extends ConnectivityError { export const isNotFoundError = (error: unknown): error is BackendError => error instanceof BackendError && error.code === ErrorCode.BACKEND_NOT_FOUND; +/** + * Whether repeating the same request can succeed. Auth, not-found, invalid-input and client errors + * are final; network, service and other backend failures may be transient. + */ +export const isRetryableError = (error: unknown): boolean => { + if (error instanceof AuthRequired || error instanceof ClientError || error instanceof BadRequestError) return false; + if (isNotFoundError(error)) return false; + if (error instanceof BackendError && AUTH_DETAIL_ERROR_CODES.includes(error.code)) return false; + if ( + error instanceof BackendError && + (error.code === ErrorCode.BACKEND_FORBIDDEN || error.code === ErrorCode.BACKEND_SPOTIFY_AUTHORIZATION_REQUIRED) + ) + return false; + return true; +}; + /** * Union type of all error classes. * Required because TypeScript's type system doesn't automatically recognize diff --git a/packages/app-kit/src/transport/fetch-wrapper.test.ts b/packages/app-kit/src/transport/fetch-wrapper.test.ts index ac5d927..ce4c86a 100644 --- a/packages/app-kit/src/transport/fetch-wrapper.test.ts +++ b/packages/app-kit/src/transport/fetch-wrapper.test.ts @@ -143,16 +143,18 @@ describe("fetchWrapper", () => { expect(createAppErrorFromResultMock).toHaveBeenCalledWith(response, undefined); }); - it("calls handleError and returns null for a non-ok response when handleError is provided", async () => { + it("calls handleError then rejects with the app error for a non-ok GET, so callers keep the typed error", async () => { const response = { ok: false } as unknown as Response; vi.mocked(fetch).mockResolvedValue(response); const appError = new AppError(ErrorCode.BACKEND_AUTH_ERROR); createAppErrorFromResultMock.mockResolvedValue(appError); const handleError = vi.fn(); - const result = await fetchWrapper("things/", false, {}, undefined, undefined, undefined, handleError); + await expect( + fetchWrapper("things/", false, {}, undefined, undefined, undefined, handleError), + ).rejects.toBe(appError); - expect(result).toBeNull(); + expect(handleError).toHaveBeenCalledTimes(1); expect(handleError).toHaveBeenCalledWith(appError); }); @@ -195,11 +197,11 @@ describe("fetchWrapper", () => { vi.useFakeTimers(); const pending = fetchWrapper("things/", false, {}, undefined, undefined, undefined, handleError); + const settled = expect(pending).rejects.toBe(appError); await vi.runAllTimersAsync(); - const result = await pending; + await settled; vi.useRealTimers(); - expect(result).toBeNull(); expect(handleError).toHaveBeenCalledWith(appError); expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3); }); @@ -263,9 +265,10 @@ describe("fetchWrapper", () => { it("passes a malformed-request ClientError to handleError", async () => { const handleError = vi.fn(); - const result = await fetchWrapper("things/", false, { body: "x" }, undefined, undefined, undefined, handleError); + await expect( + fetchWrapper("things/", false, { body: "x" }, undefined, undefined, undefined, handleError), + ).rejects.toMatchObject({ code: ErrorCode.CLIENT_INTERNAL_ERROR }); - expect(result).toBeNull(); expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ code: ErrorCode.CLIENT_INTERNAL_ERROR })); expect(vi.mocked(fetch)).not.toHaveBeenCalled(); }); @@ -332,7 +335,9 @@ describe("fetchWrapper", () => { createNetworkOrBackendErrorMock.mockReturnValue(new AppError(ErrorCode.NETWORK_TIMEOUT)); const handleError = vi.fn(); - await fetchWrapper("things/", false, { signal: controller.signal }, undefined, undefined, undefined, handleError); + await expect( + fetchWrapper("things/", false, { signal: controller.signal }, undefined, undefined, undefined, handleError), + ).rejects.toBeInstanceOf(AppError); expect(handleError).toHaveBeenCalled(); }); @@ -361,7 +366,7 @@ describe("fetchWrapper", () => { vi.mocked(fetch).mockResolvedValue({ ok: false, status: 500 } as unknown as Response); createAppErrorFromResultMock.mockResolvedValue(new AppError(ErrorCode.BACKEND_AUTH_ERROR)); - await fetchWrapper("things/", false, {}, undefined, undefined, undefined, vi.fn()); + await expect(fetchWrapper("things/", false, {}, undefined, undefined, undefined, vi.fn())).rejects.toBeInstanceOf(AppError); expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); }); @@ -372,7 +377,7 @@ describe("fetchWrapper", () => { createAppErrorFromResultMock.mockResolvedValue(new AppError(ErrorCode.BACKEND_AUTH_ERROR)); const handleError = vi.fn(); - await fetchWrapper( + await expect(fetchWrapper( "things/", false, {}, @@ -382,7 +387,7 @@ describe("fetchWrapper", () => { handleError, false, "https://backend.example.com", - ); + )).rejects.toBeInstanceOf(AppError); expect(createAppErrorFromResultMock).toHaveBeenCalledWith(response, "https://backend.example.com"); }); diff --git a/packages/app-kit/src/transport/fetch-wrapper.ts b/packages/app-kit/src/transport/fetch-wrapper.ts index 9d337b1..e666b06 100644 --- a/packages/app-kit/src/transport/fetch-wrapper.ts +++ b/packages/app-kit/src/transport/fetch-wrapper.ts @@ -116,11 +116,8 @@ export const fetchWrapper = async ( appError = createNetworkOrBackendError(caughtError, finalUrl, backendBaseUrl); } - if (!handleError) throw appError; - handleError(appError); - // A mutation resolving to null reads as a schema mismatch downstream and hides the failure from onError. - const method = (finalOptions.method ?? "GET").toUpperCase(); - if (method !== "GET" && method !== "HEAD") throw appError; - return null; + // Report it, then still reject: callers (React Query's retry policy) need the typed error. + handleError?.(appError); + throw appError; } }; diff --git a/packages/app-kit/src/transport/lib/resource-result.test.ts b/packages/app-kit/src/transport/lib/resource-result.test.ts index 43615a3..7df14d2 100644 --- a/packages/app-kit/src/transport/lib/resource-result.test.ts +++ b/packages/app-kit/src/transport/lib/resource-result.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { BackendError } from "../app-errors/app-error"; +import { AuthRequired, BackendError, BadRequestError, NetworkError, ServiceError } from "../app-errors/app-error"; import { ErrorCode } from "../app-errors/app-error-codes"; import { resourceRetry, toResourceResult } from "./resource-result"; @@ -31,6 +31,20 @@ describe("toResourceResult", () => { }); describe("resourceRetry", () => { + it("never retries an AuthRequired, so a rejected token gives one request", () => { + expect(resourceRetry(0, new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED))).toBe(false); + }); + + it("never retries a bad request or an auth-detail backend error", () => { + expect(resourceRetry(0, new BadRequestError(ErrorCode.BACKEND_INVALID_INPUT))).toBe(false); + expect(resourceRetry(0, new BackendError(ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR))).toBe(false); + }); + + it("retries network and service failures", () => { + expect(resourceRetry(0, new NetworkError(ErrorCode.NETWORK_TIMEOUT))).toBe(true); + expect(resourceRetry(0, new ServiceError(ErrorCode.SERVICE_BAD_REQUEST))).toBe(true); + }); + it("never retries a 404", () => { expect(resourceRetry(0, notFound)).toBe(false); }); diff --git a/packages/app-kit/src/transport/lib/resource-result.ts b/packages/app-kit/src/transport/lib/resource-result.ts index ca6882c..fca36c0 100644 --- a/packages/app-kit/src/transport/lib/resource-result.ts +++ b/packages/app-kit/src/transport/lib/resource-result.ts @@ -1,10 +1,10 @@ -import { isNotFoundError } from "../app-errors/app-error"; +import { isNotFoundError, isRetryableError } from "../app-errors/app-error"; const MAX_RESOURCE_ATTEMPTS = 3; -/** React Query `retry` for a single resource: a 404 is final, other failures retry up to three times. */ +/** React Query `retry` for a single resource: final errors (see `isRetryableError`) never retry, others retry up to three times. */ export const resourceRetry = (failureCount: number, error: Error) => - !isNotFoundError(error) && failureCount < MAX_RESOURCE_ATTEMPTS; + isRetryableError(error) && failureCount < MAX_RESOURCE_ATTEMPTS; export type ResourceResult = | { status: "idle" } diff --git a/packages/app-kit/src/transport/lib/resource-retry.integration.test.tsx b/packages/app-kit/src/transport/lib/resource-retry.integration.test.tsx new file mode 100644 index 0000000..0a00981 --- /dev/null +++ b/packages/app-kit/src/transport/lib/resource-retry.integration.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AuthRequired, NetworkError } from "../app-errors/app-error"; +import { ErrorCode } from "../app-errors/app-error-codes"; +import { resourceRetry } from "./resource-result"; + +const run = (queryFn: () => Promise) => { + const client = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook( + () => useQuery({ queryKey: ["r"], queryFn, retry: resourceRetry, retryDelay: 0 }), + { wrapper }, + ); +}; + +describe("resourceRetry with a real query", () => { + it("fetches once and ends in AuthRequired on 401 BAC4010", async () => { + const queryFn = vi.fn().mockRejectedValue(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); + const { result } = run(queryFn); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryFn).toHaveBeenCalledTimes(1); + expect(result.current.error).toBeInstanceOf(AuthRequired); + }); + + it("still retries network failures up to three times", async () => { + const queryFn = vi.fn().mockRejectedValue(new NetworkError(ErrorCode.NETWORK_ERROR)); + const { result } = run(queryFn); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryFn).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/app-kit/src/transport/lib/use-backend-request.ts b/packages/app-kit/src/transport/lib/use-backend-request.ts index d2aca8d..3d667c5 100644 --- a/packages/app-kit/src/transport/lib/use-backend-request.ts +++ b/packages/app-kit/src/transport/lib/use-backend-request.ts @@ -6,7 +6,7 @@ import { useSession } from "../../auth/SessionContext"; import { BackendError, AuthRequired, ConnectivityError, isNotFoundError } from "../app-errors/app-error"; import { fetchWrapper as rawFetch } from "../fetch-wrapper"; import { createAppErrorFromErrorCode } from "../app-errors/app-error-factory"; -import { ErrorCode } from "../app-errors/app-error-codes"; +import { AUTH_DETAIL_ERROR_CODES, ErrorCode } from "../app-errors/app-error-codes"; export type ResponseHook = (response: Response) => void | Promise; @@ -20,14 +20,7 @@ export const useBackendRequest = (getBackendBaseUrl: () => string) => { const handleError = useCallback((error: Error, isRead: boolean) => { if (error instanceof ConnectivityError) { - const authDetailErrors = [ - ErrorCode.BACKEND_GOOGLE_OAUTH_CODE_INVALID_OR_EXPIRED, - ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST, - ErrorCode.BACKEND_SPOTIFY_AUTHENTICATION_ERROR, - ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR, - ErrorCode.BACKEND_GOOGLE_OAUTH_MISCONFIGURED, - ]; - if (error instanceof BackendError && authDetailErrors.includes(error.code)) { + if (error instanceof BackendError && AUTH_DETAIL_ERROR_CODES.includes(error.code)) { throw error; } // A missing resource is the reader's state to show, not a connectivity failure. A write that diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dea51f8..56aa0c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ catalogs: specifier: ^12.12.0 version: 12.12.0 '@behindthemusictree/genre-tree-view': - specifier: 9.2.1 - version: 9.2.1 + specifier: 10.0.0 + version: 10.0.0 '@behindthemusictree/ui': specifier: ^0.21.2 version: 0.21.2 @@ -45,7 +45,7 @@ importers: version: 12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 9.2.1(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.0.0(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/ui': specifier: 'catalog:' version: 0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -152,7 +152,7 @@ importers: dependencies: '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 9.2.1(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.0.0(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -344,8 +344,8 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' - '@behindthemusictree/genre-tree-view@9.2.1': - resolution: {integrity: sha512-xM2+eIS/1T43pYhZFMKQDUvBqWwgnzOXJof64S5KoSC5P58snqXvztOpMWFSRWGJ70srR/NHJR5H5LsbDXeX5w==} + '@behindthemusictree/genre-tree-view@10.0.0': + resolution: {integrity: sha512-d/aToyeK28/Un12aNtVHbf2Uzm6Gmql8lYscWQVNYLpaGmMHAXet+etI/6BNJVb3vpny735Grk3x3/v3Z5wdYA==} peerDependencies: '@behindthemusictree/ui': ^0.21.2 react: '>=18.0.0' @@ -2312,7 +2312,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@behindthemusictree/genre-tree-view@9.2.1(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@behindthemusictree/genre-tree-view@10.0.0(@behindthemusictree/ui@0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@behindthemusictree/ui': 0.21.2(@behindthemusictree/brand@12.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) d3: 7.9.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c0df207..e3fb416 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ packages: catalog: "@behindthemusictree/brand": ^12.12.0 - "@behindthemusictree/genre-tree-view": "9.2.1" + "@behindthemusictree/genre-tree-view": "10.0.0" "@behindthemusictree/ui": ^0.21.2 allowBuilds: