v4: the notifications page restores its list and scroll on Back, and shows what it missed — it now saves its loaded pages and scroll position as it is left and restores them on Back, through useFeedCache, useScrollSave and useScrollRestore like the feeds and the profile's tabs; the saved list keeps the ones the server confirmed read as read, so coming back doesn't highlight them again. The page never refetched after a reconnect, so notifications sent while the socket was down never showed, and the next read-all marked them read unseen; a reconnect, and every restored list (after a reload the socket hasn't said yet whether anything is new), now fetch the first page again, and what is new goes on top with the socket's arrivals, those alone sorted newest first so the server's order stays. Marking read now follows the unread notifications shown, each sent once, instead of each socket message, and a list with none unread sends nothing. The arrivals shift the server's offset pages, so a next page could repeat the last one's tail: each notification shows once. The next page loads as the end scrolls into view, with FeedFooter, as every other page list does, and the loading and error states go through ListState. The "N new" pill was a number before a separately translated "new", which French made "3 nouveau"; it is one plural message, "3 nouvelles". Dates past a week and the tooltip use the app's locale rather than the browser's. The mention, comment and like icons had no colored circle; they have one now, and the decorative emoji are hidden from screen readers. 670 frontend tests pass (new: loading on scroll, the save on leaving, the restore with what came meanwhile, the reconnect backfill), and tsc, deno fmt, deno lint, doc:lint and i18n:compile --strict are clean; the restore was checked in the running app at phone width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 19:54:31 +00:00
parent 0a16d3c96c
commit 58c95dc4f3
8 changed files with 393 additions and 220 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../../shared/notification.ts";
@@ -7,6 +7,7 @@ import { calls, deferred, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const me = makeSession({ id: "me" });
const DAY = 86_400_000;
@@ -21,14 +22,20 @@ function open(items: Notification[], { hasMore = false } = {}) {
);
route("POST /api/me/notifications/read", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me, route: "/notifications" });
const view = renderApp(<Notifications />, {
session: me,
route: "/notifications",
});
const socket = FakeWebSocket.last;
socket.welcome({
unreadNotificationCount: items.filter((n) => !n.read).length,
});
return { user, socket };
return { user, socket, view };
}
/** The list as the page saves it on leaving, which a Back restores. */
const CACHE_KEY = "feed:notifications:me";
const n = {
follow: makeNotification({
type: "user_followed",
@@ -260,6 +267,26 @@ describe("Notifications", () => {
);
});
test("one missed while the socket was down shows after a reconnect, and is marked read", async () => {
const missed = { ...n.upvoted, createdAt: new Date() };
route("GET /api/me/notifications", () => page([n.follow]), { once: true });
const { socket } = open([missed, n.follow]);
await screen.findByText(/started following you/);
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome({ unreadNotificationCount: 1 });
const upvoted = await screen.findByText(/^upvoted$/);
expect(document.querySelector(".notification-item"))
.toContainElement(upvoted);
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(2)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
});
test("one that arrives while the list is loading isn't lost", async () => {
const list = deferred<unknown>();
route("GET /api/me/notifications", () => list.promise);
@@ -274,7 +301,7 @@ describe("Notifications", () => {
// read-all after the load will mark it read.
socket.receive({
type: "notification_created",
notification: wire(n.upvoted),
notification: wire({ ...n.upvoted, createdAt: new Date() }),
});
// And one the list does have, sent while it was on its way: shown once.
socket.receive({
@@ -295,7 +322,7 @@ describe("Notifications", () => {
expect(calls("POST /api/me/notifications/read")).toHaveLength(1);
});
test("load more", async () => {
test("the next page loads as the end scrolls into view", async () => {
route(
"GET /api/me/notifications",
(req) =>
@@ -304,11 +331,55 @@ describe("Notifications", () => {
: page([n.upvoted], false),
);
route("POST /api/me/notifications/read", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not
.toBeInTheDocument();
await screen.findByText(/started following you/);
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
expect(await screen.findByText(/^upvoted$/)).toBeInTheDocument();
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
});
test("leaving saves the list, with the ones marked read as read", async () => {
const { view } = open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
view.unmount();
const saved = JSON.parse(sessionStorage.getItem(CACHE_KEY)!);
expect(saved.items.map((x: Notification) => [x.id, x.read])).toEqual([
[n.follow.id, true],
[n.upvoted.id, true],
]);
});
test("going back restores the list and the scroll, with what came meanwhile on top", async () => {
sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({
items: [wire({ ...n.follow, read: true })],
page: 1,
hasMore: false,
scrollY: 420,
savedAt: Date.now(),
}),
);
const scrollTo = vi.spyOn(globalThis, "scrollTo");
const newer = { ...n.upvoted, createdAt: new Date(Date.now() + 1000) };
// A MemoryRouter's first entry is a POP, as after Back.
open([newer, { ...n.follow, read: true }]);
// Shown before the page is fetched again.
expect(screen.getByText(/started following you/)).toBeInTheDocument();
expect(scrollTo).toHaveBeenCalledWith(0, 420);
const upvoted = await screen.findByText(/^upvoted$/);
const items = document.querySelectorAll(".notification-item");
expect(items).toHaveLength(2);
expect(items[0]).toContainElement(upvoted);
expect(items[0]).toHaveClass("notification-item--unread");
expect(items[1]).not.toHaveClass("notification-item--unread");
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
);
expect(calls("GET /api/me/notifications")).toHaveLength(1);
});
});