v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Route } from "react-router";
|
||||
import { Dump } from "../../pages/Dump.tsx";
|
||||
@@ -237,6 +237,69 @@ describe("Dump page", () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a dump deleted, or made private, while on screen gives way to a notice", async () => {
|
||||
dumpRoutes();
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
socket.receive({ type: "dump_deleted", dumpId: "d1" });
|
||||
expect(screen.getByText("This dump was deleted, or made private."))
|
||||
.toBeInTheDocument();
|
||||
// Made public again by its poster.
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "d1", slug: "a-find", title: "Back" })),
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a reconnect fetches the dump again", async () => {
|
||||
const d = dumpRoutes();
|
||||
route("GET /api/dumps/:id", () => wire({ ...d, title: "Edited offline" }));
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
socket.drop();
|
||||
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(await screen.findByRole("heading", { name: "Edited offline" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a reconnect finds a dump deleted meanwhile", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
socket.drop();
|
||||
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(
|
||||
await screen.findByText("This dump was deleted, or made private."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a renamed commenter's name follows", async () => {
|
||||
dumpRoutes();
|
||||
route(
|
||||
"GET /api/dumps/:id/comments",
|
||||
() =>
|
||||
wire([
|
||||
makeComment({
|
||||
id: "c1",
|
||||
dumpId: "d1",
|
||||
userId: "u9",
|
||||
authorUsername: "oldname",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByText("oldname");
|
||||
socket.receive({
|
||||
type: "user_updated",
|
||||
user: wire(makeUser({ id: "u9", username: "newname" })),
|
||||
});
|
||||
expect(screen.getByText("newname")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adding to a playlist", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/me/playlists", () => [
|
||||
|
||||
@@ -248,6 +248,22 @@ describe("Index feeds", () => {
|
||||
expect(screen.getByText("Another song")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a dump edited out of the category leaves its feed", async () => {
|
||||
const song = makeDump({
|
||||
id: "s",
|
||||
title: "A song",
|
||||
categoryIds: ["c-music"],
|
||||
});
|
||||
route("GET /api/dumps", () => page([song]));
|
||||
const { socket } = setup("/music/new");
|
||||
await screen.findByText("A song");
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...song, categoryIds: [] }),
|
||||
});
|
||||
expect(screen.queryByText("A song")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown category is a 404", async () => {
|
||||
route("GET /api/dumps", () => fail("nope", 404));
|
||||
setup("/nonsense/new");
|
||||
@@ -306,6 +322,74 @@ describe("Followed feed", () => {
|
||||
expect(currentPath()).toBe("/follow");
|
||||
});
|
||||
|
||||
test("a followed user's new dump arrives live, others' don't", async () => {
|
||||
route("GET /api/me/follow", () => ({
|
||||
followedUserIds: ["friend"],
|
||||
followedPlaylistIds: [],
|
||||
}));
|
||||
route(
|
||||
"GET /api/dumps",
|
||||
(req) =>
|
||||
page(
|
||||
req.url.searchParams.get("follow") === "users"
|
||||
? [
|
||||
makeDump({
|
||||
title: "By a friend",
|
||||
userId: "friend",
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const { socket } = setup("/follow", { session: makeSession() });
|
||||
await screen.findByText("By a friend");
|
||||
// The follows load beside the feed: their answer must be in.
|
||||
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
|
||||
await act(() => new Promise((r) => setTimeout(r, 0)));
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ title: "Fresh", userId: "friend" })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ title: "A stranger's", userId: "stranger" })),
|
||||
});
|
||||
expect(screen.getByText("Fresh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("A stranger's")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a followed playlist's new dumps bring its view up to date", async () => {
|
||||
route("GET /api/me/follow", () => ({
|
||||
followedUserIds: [],
|
||||
followedPlaylistIds: ["mix"],
|
||||
}));
|
||||
route("GET /api/dumps", () => page([]), { once: true });
|
||||
route("GET /api/dumps", () => page([]), { once: true });
|
||||
route(
|
||||
"GET /api/dumps",
|
||||
(req) =>
|
||||
page(
|
||||
req.url.searchParams.get("follow") === "playlists"
|
||||
? [makeDump({ title: "Added to the mix" })]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const { socket } = setup("/follow/playlists", { session: makeSession() });
|
||||
await screen.findByText(
|
||||
"Follow some public playlists to see their dumps here.",
|
||||
);
|
||||
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
|
||||
await act(() => new Promise((r) => setTimeout(r, 0)));
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "mix",
|
||||
dumpIds: ["x"],
|
||||
dumpCount: 1,
|
||||
});
|
||||
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("its view is in the path, a category's too", async () => {
|
||||
route("GET /api/dumps", () => page([]));
|
||||
setup("/music/follow/playlists", { session: makeSession() });
|
||||
|
||||
@@ -440,18 +440,47 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("a dump that goes private leaves, and comes back at its place", async () => {
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "b", title: "Bravo" })),
|
||||
);
|
||||
const { socket } = open({ session: visitor });
|
||||
await screen.findByText("Bravo");
|
||||
// What the server sends others when it goes private, then public again.
|
||||
socket.receive({ type: "dump_deleted", dumpId: "b" });
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "p1",
|
||||
dumpIds: ["a", "c"],
|
||||
dumpCount: 3,
|
||||
});
|
||||
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "b", title: "Bravo" })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "p1",
|
||||
dumpIds: ["a", "b", "c"],
|
||||
dumpCount: 3,
|
||||
});
|
||||
expect(await screen.findByText("Bravo")).toBeInTheDocument();
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
});
|
||||
|
||||
test("a playlist made private by its owner sends a visitor home", async () => {
|
||||
const { socket } = open({ session: visitor });
|
||||
await screen.findByText("Alpha");
|
||||
// What the server sends everyone but the owner.
|
||||
socket.receive({
|
||||
type: "playlist_deleted",
|
||||
playlistId: "p1",
|
||||
userId: "owner",
|
||||
});
|
||||
expect(await screen.findByText("home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("deleting the playlist asks, then goes home", async () => {
|
||||
route(
|
||||
"DELETE /api/playlists/:id",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
function results(
|
||||
{
|
||||
@@ -209,4 +210,32 @@ describe("Search", () => {
|
||||
await screen.findByText("About two");
|
||||
expect(screen.queryByText("About one")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the socket keeps the results live, and adds none", async () => {
|
||||
const found = makeDump({ id: "d1", title: "Cat video" });
|
||||
route("GET /api/search", () =>
|
||||
results({
|
||||
dumps: [found, makeDump({ id: "d2", title: "Cat photo" })],
|
||||
playlists: [makePlaylist({ id: "p1", title: "Cats" })],
|
||||
}));
|
||||
const user = open("/search?q=cat");
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome();
|
||||
await screen.findByText("Cat video");
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...found, title: "Cat video, remastered" }),
|
||||
});
|
||||
socket.receive({ type: "dump_deleted", dumpId: "d2" });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ title: "Cat news" })),
|
||||
});
|
||||
expect(screen.getByText("Cat video, remastered")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cat photo")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Cat news")).not.toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "u" });
|
||||
await user.click(screen.getByRole("button", { name: /Playlists/ }));
|
||||
expect(screen.queryByText("Cats")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +177,38 @@ describe("UserPublicProfile", () => {
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
|
||||
test("the dumps tab gains her new dumps live, and reads its pages again on a reconnect", async () => {
|
||||
const old = makeDump({
|
||||
id: "o",
|
||||
title: "Older",
|
||||
userId: "ann",
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
const { socket } = open({ url: "/@ann/dumps", dumps: [old] });
|
||||
await screen.findByText("Older");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "n", title: "Fresh", userId: "ann" })),
|
||||
});
|
||||
// Someone else's goes to their own profile.
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "x", title: "Not hers", userId: "bob" })),
|
||||
});
|
||||
const titles = () =>
|
||||
Array.from(document.querySelectorAll(".dump-card-title"))
|
||||
.map((a) => a.textContent);
|
||||
expect(titles()).toEqual(["Fresh", "Older"]);
|
||||
|
||||
// Deleted while the socket was down.
|
||||
route("GET /api/users/:u/dumps", () => page([old]));
|
||||
socket.drop();
|
||||
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||
FakeWebSocket.last.welcome();
|
||||
await waitFor(() => expect(titles()).toEqual(["Older"]));
|
||||
});
|
||||
|
||||
test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
const tagged = makeDump({
|
||||
|
||||
Reference in New Issue
Block a user