fix(playlists): one push or revert at a time for a list, whichever menu started it

The in-flight flag was per hook instance, and the rail row's menu and the list page header's "…"
are two at once: a push from one left the other offering a second push. It is one app-wide store
(`lib/store`), read with `get()` at the start so a second start refuses before a render; a revert
re-reads it after its confirm dialog.
This commit is contained in:
2026-09-27 14:51:37 +02:00
parent 98bf0a9d66
commit 98bb841a6a
2 changed files with 43 additions and 7 deletions
@@ -155,3 +155,29 @@ describe("usePlaylistActions — a playlist's menu", () => {
);
});
});
// E28 review C1: the push / revert in-flight flag was per hook instance, and two instances run at
// once (the rail row's menu and the list page header's "…") — a push started from one left the other
// offering a second push of the same list.
describe("usePlaylistActions — one push at a time, across menus", () => {
it("a push started from one menu is off in the other until it ends", async () => {
let finish: (v: unknown) => void = () => {};
vi.spyOn(api, "playlistPushPlan").mockReturnValue(
new Promise((r) => (finish = r)) as ReturnType<typeof api.playlistPushPlan>,
);
const rail = mount();
const header = mount();
const dirty = { ...linked, dirty: true };
const push = (m: typeof rail) =>
m.result.current.items(dirty, { onRename: () => {}, pin: [] }).find((i) => i.id === "push")!;
act(() => push(rail).onSelect!());
expect(push(header).disabled).toBe(true);
// A second start (a keyboard or a stale menu) is refused, not run beside the first.
await act(async () => push(header).onSelect!());
expect(api.playlistPushPlan).toHaveBeenCalledTimes(1);
await act(async () => {
finish({ action: "update", affordable: true });
});
expect(push(header).disabled).toBeFalsy();
});
});
+17 -7
View File
@@ -1,4 +1,3 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, RotateCcw, Trash2, Upload, Youtube } from "lucide-react";
@@ -12,6 +11,7 @@ import { useChoice, useConfirm } from "./confirmContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../lib/useMe";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { createStore } from "../lib/store";
// A playlist's ONE action list (E28 C1) and the mutations behind it. Two entry points open it — the
// rail row (right-click, long-press, its "…") and the list page header's "…" — so it lives here,
@@ -24,6 +24,15 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
* and `PlaylistDetail` (the detail pane), which is why neither is named here. */
type Menuable = Pick<Playlist, "id" | "name" | "kind" | "yt_playlist_id" | "dirty">;
// Which list a push / revert is running for, so its row is off (and says so) until it is done. ONE
// for the app, not per hook: the rail row's menu and the list page header's "…" are two instances at
// once, and a per-instance flag left the other offering a second push of the same list (E28 review
// C1). Read with `get()` at the start, so a second start refuses before any render has happened.
const inFlight = createStore<{ pushing: number | null; reverting: number | null }>({
pushing: null,
reverting: null,
});
export function usePlaylistActions() {
const qc = useQueryClient();
const { t } = useTranslation();
@@ -32,9 +41,9 @@ export function usePlaylistActions() {
const canWrite = useMe().can_write;
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
// Which list a push / revert is running for, so its row is off (and says so) until it is done.
const [pushing, setPushing] = useState<number | null>(null);
const [reverting, setReverting] = useState<number | null>(null);
const { pushing, reverting } = inFlight.use();
const setPushing = (id: number | null) => inFlight.set((s) => ({ ...s, pushing: id }));
const setReverting = (id: number | null) => inFlight.set((s) => ({ ...s, reverting: id }));
const refresh = (id: number) => {
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -96,14 +105,15 @@ export function usePlaylistActions() {
}
async function revert(pl: Menuable) {
if (reverting != null) return;
if (inFlight.get().reverting != null) return;
const ok = await confirm({
title: t("playlists.revertTitle"),
message: t("playlists.revertMsg"),
confirmLabel: t("playlists.revertConfirm"),
danger: true,
});
if (!ok) return;
// Re-read after the dialog: another menu may have started one while this one asked.
if (!ok || inFlight.get().reverting != null) return;
setReverting(pl.id);
try {
await api.revertPlaylist(pl.id);
@@ -120,7 +130,7 @@ export function usePlaylistActions() {
}
async function push(pl: Menuable) {
if (pushing != null) return;
if (inFlight.get().pushing != null) return;
const name = playlistName(pl, t);
setPushing(pl.id);
try {