inspector/clients/web/src/hooks/usePaginatedListsOverride.test.tsx
cliffhall 7098da7473 fix: keep the pagination toggle on the last write across a server switch (#2095)
The displayed `paginatedLists` value falls back to the active `servers`
entry, and that list only advances when a `GET /api/servers` succeeds — so
once a list read fails it keeps describing disk as it was before the writes
made since. The optimistic override papered over that, but it was a single
app-wide slot cleared on every change of the *active* entry, so switching to
another server and back dropped it and the UI fell back to the stale entry.

Two things then disagreed, and the one the user could see was the wrong one:
the toggle read `off` while disk, the write tracker, and the client just
built from it all said `on`; and because the client skipped its connect-time
aggregate walk, the all-pages UI rendered an aggregate that was never
fetched, with no Load-next-page control to fill it.

Hold the override per server instead, in `usePaginatedListsOverride`, paired
with that server's entry as of when it was recorded and believed only while
the list still carries that same entry object — identity, not equality, so a
read reporting the value the override replaced supersedes it too. A server's
override then survives a switch away and back, and is still superseded by
the first successful read that rebuilds its entry.

The read cannot instead move to `lastPersistedSettings.resolve(id)`: that is
called during render, and it both reads refs and prunes superseded records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 20:20:57 -04:00

148 lines
5.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { act } from "react";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
usePaginatedListsOverride,
type PaginatedListsOverride,
} from "./usePaginatedListsOverride";
/**
* One `servers` entry, a fresh object per call — every test here turns on
* object identity, so a shared fixture would defeat them. A list rebuilt from
* fresh calls is what a successful `GET /api/servers` produces.
*/
const entry = (id: string): ServerEntry => ({
id,
name: id,
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
});
interface Harness {
/** The hook's API as of the latest render. */
api: () => PaginatedListsOverride;
/** Deliver a new server list, as a successful list read would. */
setServers: (next: ServerEntry[]) => void;
}
function harness(initial: ServerEntry[]): Harness {
let latest: PaginatedListsOverride | undefined;
function Probe({ servers }: { servers: ServerEntry[] }) {
latest = usePaginatedListsOverride(servers);
return null;
}
const { rerender } = renderWithMantine(<Probe servers={initial} />);
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
setServers: (next) => {
act(() => rerender(<Probe servers={next} />));
},
};
}
describe("usePaginatedListsOverride", () => {
it("reports no override before anything is recorded", () => {
const { api } = harness([entry("A")]);
expect(api().valueFor("A")).toBeUndefined();
});
it("reports nothing for an undefined server id", () => {
// No server is active before the first connect, and the composition root
// asks for the display value on every render regardless.
const { api } = harness([entry("A")]);
act(() => api().record("A", true));
expect(api().valueFor(undefined)).toBeUndefined();
});
it("returns what was recorded for that server", () => {
const { api } = harness([entry("A")]);
act(() => api().record("A", true));
expect(api().valueFor("A")).toBe(true);
});
it("does not apply one server's override to another", () => {
// The override is the *display* value, and the settings modal can be saved
// for a server that is not the connected one — so a record made for B must
// not change what A renders.
const { api } = harness([entry("A"), entry("B")]);
act(() => api().record("B", true));
expect(api().valueFor("A")).toBeUndefined();
expect(api().valueFor("B")).toBe(true);
});
it("survives a server switch away and back (#2095)", () => {
// Nothing about the list changes here — only which server is being asked
// about, which is all a switch is. The previous app-wide slot was cleared
// on every change of the active entry, which is what dropped A's value.
const { api } = harness([entry("A"), entry("B")]);
act(() => api().record("A", true));
expect(api().valueFor("B")).toBeUndefined();
expect(api().valueFor("A")).toBe(true);
});
it("keeps a record for each server", () => {
const { api } = harness([entry("A"), entry("B")]);
act(() => api().record("A", true));
act(() => api().record("B", false));
expect(api().valueFor("A")).toBe(true);
expect(api().valueFor("B")).toBe(false);
});
it("replaces its own record for the same server", () => {
const { api } = harness([entry("A")]);
act(() => api().record("A", true));
act(() => api().record("A", false));
expect(api().valueFor("A")).toBe(false);
});
it("stops applying once a fresh list read replaces that entry", () => {
const { api, setServers } = harness([entry("A")]);
act(() => api().record("A", true));
// A successful `GET /api/servers` rebuilds the list, so the entry is a new
// object even when the values are unchanged — which is exactly the case
// that has to supersede the override, since an edit made outside the
// Inspector can report back the value the override replaced.
setServers([entry("A")]);
expect(api().valueFor("A")).toBeUndefined();
});
it("keeps a record whose own entry was not re-read", () => {
// A read that rebuilds B says nothing about A. In practice the list is
// rebuilt wholesale, but the check is per entry and must not be widened to
// "any list change" — a reorder replaces the array while keeping every
// entry object, and is not evidence of a fresher read.
const a = entry("A");
const { api, setServers } = harness([a, entry("B")]);
act(() => api().record("A", true));
setServers([entry("B"), a]);
expect(api().valueFor("A")).toBe(true);
});
it("records against the list as it stands when the record is written", () => {
// A rollback records from a continuation that may be several renders old;
// pairing with the list captured then would make the record look superseded
// the moment it was written.
const { api, setServers } = harness([entry("A")]);
const stale = api();
setServers([entry("A")]);
act(() => stale.record("A", true));
expect(api().valueFor("A")).toBe(true);
});
it("drops superseded records rather than accumulating them", () => {
// Housekeeping, asserted through the only surface it has: a record dropped
// on the way past cannot come back, so re-reading after the entry is
// restored to the list still reports nothing.
const a = entry("A");
const { api, setServers } = harness([a, entry("B")]);
act(() => api().record("A", true));
setServers([entry("A"), entry("B")]);
act(() => api().record("B", true));
setServers([a, entry("B")]);
expect(api().valueFor("A")).toBeUndefined();
});
});