From dde0232acb1c0dbedae05f8ba572f604a6e33136 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 10 Oct 2026 08:34:32 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20a=20link=20from=20one=20dump's=20page=20t?= =?UTF-8?q?o=20another's=20opens=20that=20dump=20=E2=80=94=20it=20reloaded?= =?UTF-8?q?=20the=20one=20on=20screen:=20the=20page=20stays=20mounted=20wh?= =?UTF-8?q?en=20its=20path=20changes,=20and=20for=20one=20render=20the=20o?= =?UTF-8?q?ld=20dump=20was=20still=20loaded=20under=20the=20new=20path,=20?= =?UTF-8?q?so=20useCanonicalPath=20took=20that=20path=20for=20a=20former?= =?UTF-8?q?=20one=20and=20put=20the=20old=20dump's=20back,=20which=20was?= =?UTF-8?q?=20then=20fetched=20again.=20A=20related=20dump's=20link=20did?= =?UTF-8?q?=20it,=20and=20so=20did=20any=20dump=20link=20in=20a=20note=20o?= =?UTF-8?q?r=20a=20comment.=20The=20page=20now=20drops=20the=20dump=20on?= =?UTF-8?q?=20screen=20in=20the=20render=20that=20sees=20a=20path=20naming?= =?UTF-8?q?=20another,=20as=20usePlaylist=20and=20useProfile=20do.=20The?= =?UTF-8?q?=20dump=20handed=20over=20by=20DumpEdit=20or=20a=20title=20edit?= =?UTF-8?q?=20goes=20with=20it,=20so=20that=20coming=20back=20to=20its=20p?= =?UTF-8?q?ath=20fetches=20it=20instead=20of=20matching=20that=20snapshot?= =?UTF-8?q?=20and=20loading=20forever.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/pages/Dump.tsx | 15 +++++++- src/test/pages/Dump.test.tsx | 69 +++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 681b44f4..2e07f656 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -79,7 +79,7 @@ export function Dump(): React.JSX.Element { const navigate = useNavigate(); // Taken once, since it stays in the history entry, where a reload or Back // would show that snapshot forever. It is cleared from there below. - const [handedOver] = useState( + const [handedOver, setHandedOver] = useState( () => (location.state as { dump?: Dump } | null)?.dump ?? null, ); const preloaded = isAt(handedOver, username, slug) ? handedOver : null; @@ -95,6 +95,19 @@ export function Dump(): React.JSX.Element { preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" }, ); const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null; + // The path last rendered. One that names another dump, as a related dump's + // link does, drops the dump on screen in the same render: left for the + // fetch below to drop, useCanonicalPath would first put its path back in + // the URL. The dump handed over goes with it, so that coming back to its + // path fetches it. + const [target, setTarget] = useState({ username, slug }); + if (target.username !== username || target.slug !== slug) { + setTarget({ username, slug }); + if (!isAt(loadedDump, username, slug)) { + setHandedOver(null); + setDumpState({ status: "loading" }); + } + } useCanonicalPath(loadedDump && dumpUrl(loadedDump)); // The dump on screen, which a path it was just given, by useCanonicalPath or // a slug edited elsewhere, needn't fetch again. diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx index 92c6294f..924bb67c 100644 --- a/src/test/pages/Dump.test.tsx +++ b/src/test/pages/Dump.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { act, screen, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; -import { Route } from "react-router"; +import { Link, Route } from "react-router"; import { Dump } from "../../pages/Dump.tsx"; import { DumpEdit } from "../../pages/DumpEdit.tsx"; import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx"; @@ -225,6 +225,73 @@ describe("Dump page", () => { expect(await screen.findByText("Best of")).toBeInTheDocument(); }); + test("a related dump's link leads to that dump", async () => { + const first = dumpRoutes(); + const second = makeDump({ + id: "d2", + slug: "next-find", + title: "Next find", + }); + route( + "GET /api/users/:u/dumps/:slug", + ({ params }) => wire(params.slug === second.slug ? second : first), + ); + route( + "GET /api/dumps/:id/related", + ({ params }) => wire(params.id === first.id ? [second] : []), + ); + const { user } = openDump(); + await user.click(await screen.findByRole("link", { name: "Next find" })); + expect(await screen.findByRole("heading", { name: "Next find", level: 1 })) + .toBeInTheDocument(); + expect(currentPath()).toBe("/@owner/dumps/next-find"); + // Its parts are the new dump's. + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Related" })).not + .toBeInTheDocument() + ); + }); + + test("a dump handed over is fetched when come back to", async () => { + const first = dumpRoutes(); + const second = makeDump({ + id: "d2", + slug: "next-find", + title: "Next find", + }); + route( + "GET /api/users/:u/dumps/:slug", + ({ params }) => wire(params.slug === second.slug ? second : first), + ); + route( + "GET /api/dumps/:id/related", + ({ params }) => wire([params.id === first.id ? second : first]), + ); + const user = userEvent.setup(); + renderApp(, { + route: "/start", + path: "/:handle/dumps/:slug", + extraRoutes: ( + open + } + /> + ), + }); + FakeWebSocket.last.welcome(); + await user.click(screen.getByRole("link", { name: "open" })); + await user.click(await screen.findByRole("link", { name: "Next find" })); + await user.click(await screen.findByRole("link", { name: "A find" })); + expect(await screen.findByRole("heading", { name: "A find", level: 1 })) + .toBeInTheDocument(); + expect(calls("GET /api/users/:u/dumps/:slug").map((c) => c.path)).toEqual([ + "/api/users/@owner/dumps/next-find", + "/api/users/@owner/dumps/a-find", + ]); + }); + test("a live update of this dump is applied", async () => { const d = dumpRoutes(); const { socket } = openDump();