Skip to content

Commit ba6e63e

Browse files
authored
Merge pull request #4138 from Spinnich/fix/random-pick-non-gallery-route
fix(v2): drop a random pick that lands after leaving the gallery
2 parents 704b0b2 + 3e97df0 commit ba6e63e

6 files changed

Lines changed: 122 additions & 2 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { effectScope } from "vue";
3+
import { useIsAlive } from "./index";
4+
5+
describe("useIsAlive", () => {
6+
it("starts alive and flips on scope disposal", () => {
7+
const scope = effectScope();
8+
const alive = scope.run(() => useIsAlive())!;
9+
10+
expect(alive.value).toBe(true);
11+
scope.stop();
12+
expect(alive.value).toBe(false);
13+
});
14+
15+
it("keeps sibling scopes independent", () => {
16+
const a = effectScope();
17+
const b = effectScope();
18+
const aliveA = a.run(() => useIsAlive())!;
19+
const aliveB = b.run(() => useIsAlive())!;
20+
21+
a.stop();
22+
expect(aliveA.value).toBe(false);
23+
expect(aliveB.value).toBe(true);
24+
});
25+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// useIsAlive — tracks whether the owning effect scope is still active, so an
2+
// async handler that resolves late can tell it's talking to a dead component.
3+
import { onScopeDispose, shallowRef, type ShallowRef } from "vue";
4+
5+
export function useIsAlive(): ShallowRef<boolean> {
6+
const alive = shallowRef(true);
7+
onScopeDispose(() => {
8+
alive.value = false;
9+
});
10+
return alive;
11+
}

frontend/src/v2/views/Gallery/Collection.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,48 @@ describe("Collection view random rom", () => {
268268
expect(push).toHaveBeenCalledWith({ name: "rom", params: { rom: 7 } });
269269
});
270270

271+
// Issue #4114: leaving for a route that is not another gallery leaves
272+
// `currentCollection` set, so the id check alone cannot tell that the user
273+
// walked away.
274+
it("drops a pick that lands after the user left the gallery", async () => {
275+
let resolvePick: (value: { data: SimpleRom }) => void = () => {};
276+
getRandomRom.mockReturnValueOnce(
277+
new Promise((resolve) => {
278+
resolvePick = resolve;
279+
}),
280+
);
281+
282+
const wrapper = await mountView();
283+
await wrapper.get("button.random").trigger("click");
284+
285+
wrapper.unmount();
286+
287+
resolvePick({ data: rom(42) });
288+
await flushPromises();
289+
290+
expect(push).not.toHaveBeenCalled();
291+
});
292+
293+
it("stays quiet when a pick fails after the user left the gallery", async () => {
294+
let failPick: (reason: Error) => void = () => {};
295+
getRandomRom.mockReturnValueOnce(
296+
new Promise((_resolve, reject) => {
297+
failPick = reject;
298+
}),
299+
);
300+
301+
const wrapper = await mountView();
302+
await wrapper.get("button.random").trigger("click");
303+
304+
wrapper.unmount();
305+
306+
failPick(new Error("boom"));
307+
await flushPromises();
308+
309+
expect(snackbarError).not.toHaveBeenCalled();
310+
expect(push).not.toHaveBeenCalled();
311+
});
312+
271313
it("ignores a click while a pick is in flight", async () => {
272314
let resolvePick: (value: { data: SimpleRom }) => void = () => {};
273315
getRandomRom.mockReturnValue(

frontend/src/v2/views/Gallery/Collection.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import CollectionSettingsTab from "@/v2/components/Gallery/CollectionSettingsTab
3333
import GalleryShell from "@/v2/components/Gallery/GalleryShell.vue";
3434
import { useCan } from "@/v2/composables/useCan";
3535
import { useConfirm } from "@/v2/composables/useConfirm";
36+
import { useIsAlive } from "@/v2/composables/useIsAlive";
3637
import { usePageTitle } from "@/v2/composables/usePageTitle";
3738
import { useSnackbar } from "@/v2/composables/useSnackbar";
3839
import { useWebpSupport } from "@/v2/composables/useWebpSupport";
@@ -281,6 +282,10 @@ function randomScope(): {
281282
return { collectionId: Number(c.id) };
282283
}
283284
285+
// Leaving for anything that isn't another gallery keeps `currentCollection`
286+
// in place, so the id check in `onRandomGame` can't see the user walked away.
287+
const alive = useIsAlive();
288+
284289
// `/roms/random` samples the pick server-side, so one request resolves it
285290
// whatever the collection holds. `null` means the collection holds no roms.
286291
async function onRandomGame() {
@@ -289,7 +294,7 @@ async function onRandomGame() {
289294
randomLoading.value = true;
290295
const scopeId = c.id;
291296
// A pick from the collection the user just left leads nowhere useful.
292-
const stale = () => currentCollection.value?.id !== scopeId;
297+
const stale = () => !alive.value || currentCollection.value?.id !== scopeId;
293298
try {
294299
const { data } = await romApi.getRandomRom(randomScope());
295300
if (stale()) return;

frontend/src/v2/views/Gallery/Platform.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,38 @@ describe("Platform view random rom", () => {
278278
expect(push).toHaveBeenCalledWith({ name: "rom", params: { rom: 42 } });
279279
});
280280

281+
// Issue #4114: leaving for a route that is not another gallery never runs
282+
// `resetGallery`, so the store still holds this platform and the id check
283+
// alone cannot tell that the user walked away.
284+
it("drops a pick that lands after the user left the gallery", async () => {
285+
const resolvePick = deferRandomRom();
286+
287+
const wrapper = await mountView();
288+
await wrapper.get("button.random").trigger("click");
289+
290+
wrapper.unmount();
291+
292+
resolvePick(rom(42));
293+
await flushPromises();
294+
295+
expect(push).not.toHaveBeenCalled();
296+
});
297+
298+
it("stays quiet when a pick fails after the user left the gallery", async () => {
299+
const failPick = deferRandomRomFailure();
300+
301+
const wrapper = await mountView();
302+
await wrapper.get("button.random").trigger("click");
303+
304+
wrapper.unmount();
305+
306+
failPick();
307+
await flushPromises();
308+
309+
expect(snackbarError).not.toHaveBeenCalled();
310+
expect(push).not.toHaveBeenCalled();
311+
});
312+
281313
it("ignores a click while a pick is in flight", async () => {
282314
let resolvePick: (value: { data: SimpleRom }) => void = () => {};
283315
getRandomRom.mockReturnValue(

frontend/src/v2/views/Gallery/Platform.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import ScanPlatformDialog from "@/v2/components/Gallery/ScanPlatformDialog.vue";
3333
import SettingsTab from "@/v2/components/Gallery/SettingsTab.vue";
3434
import { useCan } from "@/v2/composables/useCan";
3535
import { useConfirm } from "@/v2/composables/useConfirm";
36+
import { useIsAlive } from "@/v2/composables/useIsAlive";
3637
import { usePageTitle } from "@/v2/composables/usePageTitle";
3738
import { useSnackbar } from "@/v2/composables/useSnackbar";
3839
import storeGalleryRoms from "@/v2/stores/galleryRoms";
@@ -325,6 +326,10 @@ function onScan() {
325326
scanOpen.value = true;
326327
}
327328
329+
// Leaving for anything that isn't another gallery keeps the store's platform
330+
// in place, so the id check in `onRandomGame` can't see the user walked away.
331+
const alive = useIsAlive();
332+
328333
// Random ROM — pick one game from this platform and jump to its
329334
// details. Mirrors the Home RandomPickWidget: `/roms/random` samples the
330335
// pick server-side, so one request resolves it whatever the platform
@@ -337,7 +342,7 @@ async function onRandomGame() {
337342
// The pick belongs to the platform that was on screen when the button was
338343
// clicked; following it after the user moved on would drop them into a
339344
// game from a gallery they already left.
340-
const stale = () => currentPlatform.value?.id !== scopeId;
345+
const stale = () => !alive.value || currentPlatform.value?.id !== scopeId;
341346
try {
342347
const { data } = await romApi.getRandomRom({ platformIds: [scopeId] });
343348
if (stale()) return;

0 commit comments

Comments
 (0)