refactor: deduplicate rollback via WatchlistStore
This commit is contained in:
@@ -11,8 +11,48 @@ type WatchlistUpdateDisplay =
|
|||||||
| "Dropped"
|
| "Dropped"
|
||||||
| "Add to Watchlist";
|
| "Add to Watchlist";
|
||||||
|
|
||||||
const watchlistIds = new Set<number>();
|
class WatchlistStore {
|
||||||
const inflight = new Set<number>();
|
private readonly watchlistIds = new Set<number>();
|
||||||
|
private readonly inflight = new Set<number>();
|
||||||
|
|
||||||
|
add(id: number): void {
|
||||||
|
this.watchlistIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: number): void {
|
||||||
|
this.watchlistIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: number): boolean {
|
||||||
|
return this.watchlistIds.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
isBusy(id: number): boolean {
|
||||||
|
return this.inflight.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
withOptimistic(id: number, onRollback: () => void): (() => void) | null {
|
||||||
|
if (this.isBusy(id)) return null;
|
||||||
|
|
||||||
|
const wasInWatchlist = this.has(id);
|
||||||
|
this.inflight.add(id);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (wasInWatchlist) {
|
||||||
|
this.add(id);
|
||||||
|
} else {
|
||||||
|
this.remove(id);
|
||||||
|
}
|
||||||
|
onRollback();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
settle(id: number): void {
|
||||||
|
this.inflight.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchlistStore = new WatchlistStore();
|
||||||
|
|
||||||
const getShowToast = (): ((opts: { message: string; duration?: number }) => void) | null => {
|
const getShowToast = (): ((opts: { message: string; duration?: number }) => void) | null => {
|
||||||
const anyWindow = window as unknown as {
|
const anyWindow = window as unknown as {
|
||||||
@@ -52,7 +92,7 @@ const requestJson = async (input: string, init: RequestInit): Promise<Response>
|
|||||||
const syncRemoveButtonVisibility = (id: number): void => {
|
const syncRemoveButtonVisibility = (id: number): void => {
|
||||||
const container = document.getElementById(`remove-watchlist-container-${id}`);
|
const container = document.getElementById(`remove-watchlist-container-${id}`);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.classList.toggle("hidden", !watchlistIds.has(id));
|
container.classList.toggle("hidden", !watchlistStore.has(id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncWatchlistDropdown = (id: number, inWatchlist: boolean): void => {
|
const syncWatchlistDropdown = (id: number, inWatchlist: boolean): void => {
|
||||||
@@ -63,7 +103,7 @@ const syncWatchlistDropdown = (id: number, inWatchlist: boolean): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncIconsForId = (id: number): void => {
|
const syncIconsForId = (id: number): void => {
|
||||||
const shouldBeInWatchlist = watchlistIds.has(id);
|
const shouldBeInWatchlist = watchlistStore.has(id);
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLElement>("[data-watchlist-toggle][data-mal-id]")
|
.querySelectorAll<HTMLElement>("[data-watchlist-toggle][data-mal-id]")
|
||||||
.forEach((button) => {
|
.forEach((button) => {
|
||||||
@@ -75,29 +115,18 @@ const syncIconsForId = (id: number): void => {
|
|||||||
"aria-label",
|
"aria-label",
|
||||||
shouldBeInWatchlist ? "Remove from Watchlist" : "Add to Watchlist",
|
shouldBeInWatchlist ? "Remove from Watchlist" : "Add to Watchlist",
|
||||||
);
|
);
|
||||||
button.toggleAttribute("aria-busy", inflight.has(id));
|
button.toggleAttribute("aria-busy", watchlistStore.isBusy(id));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBusy = (id: number, busy: boolean): void => {
|
const setBusy = (id: number, busy: boolean): void => {
|
||||||
if (busy) {
|
|
||||||
inflight.add(id);
|
|
||||||
} else {
|
|
||||||
inflight.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
document
|
|
||||||
.querySelectorAll<HTMLButtonElement>("[data-watchlist-toggle][data-mal-id]")
|
|
||||||
.forEach((button) => {
|
|
||||||
const malId = toInt(button.dataset.malId);
|
|
||||||
if (malId !== id) return;
|
|
||||||
button.disabled = busy;
|
|
||||||
button.toggleAttribute("aria-busy", busy);
|
|
||||||
});
|
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLButtonElement>(
|
.querySelectorAll<HTMLButtonElement>(
|
||||||
"[data-watchlist-update][data-mal-id], [data-watchlist-remove][data-mal-id]",
|
[
|
||||||
|
"[data-watchlist-toggle][data-mal-id]",
|
||||||
|
"[data-watchlist-update][data-mal-id]",
|
||||||
|
"[data-watchlist-remove][data-mal-id]",
|
||||||
|
].join(", "),
|
||||||
)
|
)
|
||||||
.forEach((button) => {
|
.forEach((button) => {
|
||||||
const malId = toInt(button.dataset.malId);
|
const malId = toInt(button.dataset.malId);
|
||||||
@@ -119,22 +148,27 @@ const toggleWatchlist = async (
|
|||||||
title: string,
|
title: string,
|
||||||
renderedState: string | undefined,
|
renderedState: string | undefined,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (inflight.has(id)) return;
|
|
||||||
if (renderedState === "in") {
|
if (renderedState === "in") {
|
||||||
watchlistIds.add(id);
|
watchlistStore.add(id);
|
||||||
} else if (renderedState === "out") {
|
} else if (renderedState === "out") {
|
||||||
watchlistIds.delete(id);
|
watchlistStore.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInWatchlist = watchlistIds.has(id);
|
const rollback = watchlistStore.withOptimistic(id, () => {
|
||||||
|
syncIconsForId(id);
|
||||||
|
syncWatchlistDropdown(id, watchlistStore.has(id));
|
||||||
|
});
|
||||||
|
if (!rollback) return;
|
||||||
|
|
||||||
|
const isInWatchlist = watchlistStore.has(id);
|
||||||
|
|
||||||
setBusy(id, true);
|
setBusy(id, true);
|
||||||
|
|
||||||
const optimisticNext = !isInWatchlist;
|
const optimisticNext = !isInWatchlist;
|
||||||
if (optimisticNext) {
|
if (optimisticNext) {
|
||||||
watchlistIds.add(id);
|
watchlistStore.add(id);
|
||||||
} else {
|
} else {
|
||||||
watchlistIds.delete(id);
|
watchlistStore.remove(id);
|
||||||
}
|
}
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncWatchlistDropdown(id, optimisticNext);
|
syncWatchlistDropdown(id, optimisticNext);
|
||||||
@@ -158,15 +192,10 @@ const toggleWatchlist = async (
|
|||||||
|
|
||||||
toast(optimisticNext ? `Added ${title} to watchlist` : `Removed ${title} from watchlist`);
|
toast(optimisticNext ? `Added ${title} to watchlist` : `Removed ${title} from watchlist`);
|
||||||
} catch {
|
} catch {
|
||||||
if (optimisticNext) {
|
rollback();
|
||||||
watchlistIds.delete(id);
|
|
||||||
} else {
|
|
||||||
watchlistIds.add(id);
|
|
||||||
}
|
|
||||||
syncIconsForId(id);
|
|
||||||
syncWatchlistDropdown(id, watchlistIds.has(id));
|
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
} finally {
|
} finally {
|
||||||
|
watchlistStore.settle(id);
|
||||||
setBusy(id, false);
|
setBusy(id, false);
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncRemoveButtonVisibility(id);
|
syncRemoveButtonVisibility(id);
|
||||||
@@ -353,11 +382,15 @@ const updateWatchlist = async (
|
|||||||
title: string,
|
title: string,
|
||||||
source: HTMLElement,
|
source: HTMLElement,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (inflight.has(id)) return;
|
const rollback = watchlistStore.withOptimistic(id, () => {
|
||||||
|
syncIconsForId(id);
|
||||||
|
syncRemoveButtonVisibility(id);
|
||||||
|
});
|
||||||
|
if (!rollback) return;
|
||||||
|
|
||||||
setBusy(id, true);
|
setBusy(id, true);
|
||||||
|
|
||||||
const wasInWatchlist = watchlistIds.has(id);
|
watchlistStore.add(id);
|
||||||
watchlistIds.add(id);
|
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncRemoveButtonVisibility(id);
|
syncRemoveButtonVisibility(id);
|
||||||
|
|
||||||
@@ -380,23 +413,24 @@ const updateWatchlist = async (
|
|||||||
closeClosestDropdown(source);
|
closeClosestDropdown(source);
|
||||||
toast(`Marked ${title} as ${display}`);
|
toast(`Marked ${title} as ${display}`);
|
||||||
} catch {
|
} catch {
|
||||||
if (!wasInWatchlist) {
|
rollback();
|
||||||
watchlistIds.delete(id);
|
|
||||||
}
|
|
||||||
syncIconsForId(id);
|
|
||||||
syncRemoveButtonVisibility(id);
|
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
} finally {
|
} finally {
|
||||||
|
watchlistStore.settle(id);
|
||||||
setBusy(id, false);
|
setBusy(id, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeWatchlist = async (id: number, title: string, source: HTMLElement): Promise<void> => {
|
const removeWatchlist = async (id: number, title: string, source: HTMLElement): Promise<void> => {
|
||||||
if (inflight.has(id)) return;
|
const rollback = watchlistStore.withOptimistic(id, () => {
|
||||||
|
syncIconsForId(id);
|
||||||
|
syncWatchlistDropdown(id, watchlistStore.has(id));
|
||||||
|
});
|
||||||
|
if (!rollback) return;
|
||||||
|
|
||||||
setBusy(id, true);
|
setBusy(id, true);
|
||||||
|
|
||||||
const wasInWatchlist = watchlistIds.has(id);
|
watchlistStore.remove(id);
|
||||||
watchlistIds.delete(id);
|
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
syncWatchlistDropdown(id, false);
|
syncWatchlistDropdown(id, false);
|
||||||
|
|
||||||
@@ -418,13 +452,10 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (wasInWatchlist) {
|
rollback();
|
||||||
watchlistIds.add(id);
|
|
||||||
}
|
|
||||||
syncIconsForId(id);
|
|
||||||
syncWatchlistDropdown(id, watchlistIds.has(id));
|
|
||||||
toast("Failed to update watchlist");
|
toast("Failed to update watchlist");
|
||||||
} finally {
|
} finally {
|
||||||
|
watchlistStore.settle(id);
|
||||||
setBusy(id, false);
|
setBusy(id, false);
|
||||||
syncRemoveButtonVisibility(id);
|
syncRemoveButtonVisibility(id);
|
||||||
}
|
}
|
||||||
@@ -433,7 +464,7 @@ const removeWatchlist = async (id: number, title: string, source: HTMLElement):
|
|||||||
onReady(initWatchlistPage);
|
onReady(initWatchlistPage);
|
||||||
|
|
||||||
const initWatchlist = (ids: number[]): void => {
|
const initWatchlist = (ids: number[]): void => {
|
||||||
ids.forEach((id) => watchlistIds.add(id));
|
ids.forEach((id) => watchlistStore.add(id));
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
syncRemoveButtonVisibility(id);
|
syncRemoveButtonVisibility(id);
|
||||||
syncIconsForId(id);
|
syncIconsForId(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user