From 4c846242972370275fedd6cafb34a3646afa440e Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 13:36:44 +0100 Subject: [PATCH 1/9] feat(genre-tree): add Make root to the Parent popup Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++ .../src/genre-tree/ChangeParentPopup.test.tsx | 39 +++++++++++++++++++ .../src/genre-tree/ChangeParentPopup.tsx | 34 +++++++++++++--- .../src/genre-tree/GenreTreeView.tsx | 2 +- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9802414f..3f16ac79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ easy to spot when bumping. ## [Unreleased] +### Breaking + +- `ParentChange.to` (app-kit-ui, `onParentChanged`) is now `GenreUuid | null`; `null` means the genre was made a root. Handle it before using `to`. + +### Added + +- Parent popup (`ChangeParentPopup`): a **Make root** button, shown when the genre has a parent, with an inline confirm step. + ## [33.0.0] - 2026-11-05 ### Breaking 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 f1d05cbf..be1227e3 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 b1d8dd15..6802e564 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, @@ -13,7 +13,7 @@ import type { Scope } from "@behindthemusictree/app-kit/transport"; import BasePopup from "../popup/BasePopup"; 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 +38,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 +72,26 @@ 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}. +

+
+ + +
+
+ ) : ( + + ))}
= 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. */ From 2a1b1b2dff1ff69e50d4324ecacff1eec21d9647 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 13:37:32 +0100 Subject: [PATCH 2/9] refactor(genre-tree): use the shared Button in the Make root step Co-Authored-By: Claude Sonnet 5.5 --- .../src/genre-tree/ChangeParentPopup.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx index cb7aa66d..82135448 100644 --- a/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx +++ b/packages/app-kit-ui/src/genre-tree/ChangeParentPopup.tsx @@ -10,7 +10,9 @@ 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 | null }; @@ -78,19 +80,21 @@ export default function ChangeParentPopup({

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

-
- - + -
+ +
) : ( - +
+ +
))}
Date: Thu, 12 Nov 2026 13:54:28 +0100 Subject: [PATCH 3/9] fix(transport): stop retrying requests rejected with AuthRequired GET/HEAD failures now report to the global handler and reject with the typed AppError instead of resolving null (which surfaced as a generic parse error and was retried 3x, re-raising the popup each time). isRetryableError owns the retry policy used by resourceRetry. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++++++ .../transport/app-errors/app-error-codes.ts | 9 +++++ .../transport/app-errors/app-error.test.ts | 30 ++++++++++++++- .../src/transport/app-errors/app-error.ts | 13 ++++++- .../src/transport/fetch-wrapper.test.ts | 27 ++++++++------ .../app-kit/src/transport/fetch-wrapper.ts | 9 ++--- .../src/transport/lib/resource-result.test.ts | 16 +++++++- .../src/transport/lib/resource-result.ts | 6 +-- .../lib/resource-retry.integration.test.tsx | 37 +++++++++++++++++++ .../src/transport/lib/use-backend-request.ts | 11 +----- 10 files changed, 137 insertions(+), 33 deletions(-) create mode 100644 packages/app-kit/src/transport/lib/resource-retry.integration.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 425df064..cd9419f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ easy to spot when bumping. ## [Unreleased] +### Breaking + +- `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 + +- `isRetryableError` (transport) and `AUTH_DETAIL_ERROR_CODES`: one classification of which failures a retry can fix. + +### 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, bad-request, client and auth-detail errors. + ## [42.0.2] - 2026-11-12 ### Fixed 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 6d79b0b9..78e13e50 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 948f7364..c70c2446 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,21 @@ 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); + 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 401011a5..75a21599 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,17 @@ 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; + 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 ac5d9272..ce4c86ad 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 9d337b17..e666b069 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 43615a34..7df14d2d 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 ca6882ce..fca36c04 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 00000000..0a009810 --- /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 d2aca8d3..3d667c55 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 From 7963ff0d9ad10931b72a802c50c08b8088d4eaec Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 13:58:12 +0100 Subject: [PATCH 4/9] fix(transport): keep single popup on failed track page; skip retry on 403 Map a reported AppError on the first tracks page to TracksFailureAlreadyReportedError, and treat BAC4030/BAC4031 as final. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../app-kit/src/genre-tree/TrackListContext.test.tsx | 8 ++++++++ packages/app-kit/src/genre-tree/TrackListContext.tsx | 11 +++++++++-- packages/app-kit/src/genre-tree/useGenrePlaylist.ts | 2 +- .../src/transport/app-errors/app-error.test.ts | 2 ++ .../app-kit/src/transport/app-errors/app-error.ts | 5 +++++ 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9419f3..33831374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ easy to spot when bumping. ### 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, bad-request, client and auth-detail errors. +- 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 diff --git a/packages/app-kit/src/genre-tree/TrackListContext.test.tsx b/packages/app-kit/src/genre-tree/TrackListContext.test.tsx index dab13ae4..a0c1be7f 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 7453c19f..353833bb 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 b795ba12..132daa43 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/transport/app-errors/app-error.test.ts b/packages/app-kit/src/transport/app-errors/app-error.test.ts index c70c2446..21e4d727 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 @@ -55,6 +55,8 @@ describe("isRetryableError", () => { 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); } 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 75a21599..128c6802 100644 --- a/packages/app-kit/src/transport/app-errors/app-error.ts +++ b/packages/app-kit/src/transport/app-errors/app-error.ts @@ -96,6 +96,11 @@ 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; }; From 697146cc216149a440facfa64ad8a6e03ff470d1 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 15:20:02 +0100 Subject: [PATCH 5/9] feat(genre-tree): end Ancestors on the selected genre Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ .../app-kit-ui/src/genre-tree/GenreDetailPanel.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d336d05f..9a41172f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ easy to spot when bumping. - 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 the next `@behindthemusictree/genre-tree-view` release (pending, catalog pin not bumped yet). + ### 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. diff --git a/packages/app-kit-ui/src/genre-tree/GenreDetailPanel.tsx b/packages/app-kit-ui/src/genre-tree/GenreDetailPanel.tsx index 47aae5dd..3afa94fd 100644 --- a/packages/app-kit-ui/src/genre-tree/GenreDetailPanel.tsx +++ b/packages/app-kit-ui/src/genre-tree/GenreDetailPanel.tsx @@ -152,7 +152,15 @@ export function useGenreDetailContent({ onSelectNode={selectGenre} /> ); - const ancestors = ; + const ancestors = ( + + ); return { name: detail.name, From 830b277499f975df0d4407af132d9e66b2404c72 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 15:26:26 +0100 Subject: [PATCH 6/9] feat(player): add stop() and a Close player button to PlayerPanel usePlayer().stop() pauses and unloads the current track so playerTrackObject becomes null. PlayerPanel's header gets a Close player button wired to it, and the already-ignored onClose prop is removed. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ .../src/genre-tree/PlayerPanel.test.tsx | 12 +++++++-- .../app-kit-ui/src/genre-tree/PlayerPanel.tsx | 10 ++++--- .../app-kit/src/player/PlayerContext.test.tsx | 17 ++++++++++++ packages/app-kit/src/player/PlayerContext.tsx | 26 +++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d336d05f..c5f631d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ easy to spot when bumping. ### 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. 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 30371473..110f6d20 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 4774ca98..0d7ee8cf 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,9 @@ export default function PlayerPanel({ {loadedTrackTitle || lastLoadedTrackTitle} + +
diff --git a/packages/app-kit/src/player/PlayerContext.test.tsx b/packages/app-kit/src/player/PlayerContext.test.tsx index ef19d5de..060eb7bc 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 384a98d9..616faf42 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[], @@ -357,6 +359,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 +494,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { setPlayState, handlePlayPauseAction, loadTrackForPlayer, + stop, isLoading, handleNextTrack, handlePreviousTrack, @@ -489,6 +514,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { isLoading, handlePlayPauseAction, loadTrackForPlayer, + stop, handleNextTrack, handlePreviousTrack, onTrackEnd, From 63652787caab7e3f01ebff897958af8207e06a9d Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 15:51:59 +0100 Subject: [PATCH 7/9] fix(player): ignore late loadedmetadata after stop; hide Close when idle Co-Authored-By: Claude Sonnet 5.5 --- packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx | 8 +++++--- packages/app-kit/src/player/PlayerContext.tsx | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx index 0d7ee8cf..83a14cb6 100644 --- a/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx +++ b/packages/app-kit-ui/src/genre-tree/PlayerPanel.tsx @@ -113,9 +113,11 @@ export default function PlayerPanel({ {loadedTrackTitle || lastLoadedTrackTitle} - - + {playerTrackObject && ( + + + )}
diff --git a/packages/app-kit/src/player/PlayerContext.tsx b/packages/app-kit/src/player/PlayerContext.tsx index 616faf42..6715f517 100644 --- a/packages/app-kit/src/player/PlayerContext.tsx +++ b/packages/app-kit/src/player/PlayerContext.tsx @@ -217,6 +217,7 @@ export function PlayerProvider({ children, loadTrack }: PlayerProviderProps) { audioElementRef.current = audio; audio.addEventListener("loadedmetadata", () => { + if (audioElementRef.current !== audio) return; setDuration(audio.duration); }); From 8e673b28352012741f2ad970e2cc00e9b3f4ad62 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 15:53:05 +0100 Subject: [PATCH 8/9] chore(deps): bump genre-tree-view to 10.0.0 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a41172f..a9b74039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ easy to spot when bumping. ### 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 the next `@behindthemusictree/genre-tree-view` release (pending, catalog pin not bumped yet). +- `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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dea51f85..56aa0c6e 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 c0df2073..e3fb4160 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: From 3772b9e67dadf9359406924b4e32483f36e19ed4 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 12 Nov 2026 16:47:59 +0100 Subject: [PATCH 9/9] chore(release): prepare 43.0.0 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ packages/app-kit-ui/package.json | 4 ++-- packages/app-kit/package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c434ce..7be0f2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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). diff --git a/packages/app-kit-ui/package.json b/packages/app-kit-ui/package.json index 7af32c82..2e0d460b 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/package.json b/packages/app-kit/package.json index 0808f659..1e22ffed 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",