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: