fix(web): roll a failed settings write back to what actually landed

The pagination toggle read its rollback baseline off the `servers` entry,
which only advances on a successful `GET /api/servers`. Once a list read
fails that entry describes disk as it was before every write made since,
so the #1721 rollback reverted to a value nothing on disk holds — and
clearing the optimistic override fell back to the same stale entry, so
the UI was wrong from both directions.

Remember the last settings write known to have landed (a new gated hook,
`useLastPersistedSettings`) and roll back to that instead, keyed to the
`servers` entry it was derived from so a fresh list read supersedes it.
Both settings writers — the toggle and the settings modal's debounced
persist — feed the one record.

Closes #2089

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 10:54:53 -04:00
commit 0bcb7ec78a
2 changed files with 288 additions and 14 deletions

View file

@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import type {
InspectorServerSettings,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
useLastPersistedSettings,
type LastPersistedSettings,
} from "./useLastPersistedSettings";
const settings = (
overrides: Partial<InspectorServerSettings> = {},
): InspectorServerSettings => ({
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
autoRefreshOnListChanged: false,
paginatedLists: false,
maxFetchRequests: 0,
roots: [],
...overrides,
});
/**
* One `servers` entry, as a fresh object each call — the point of most of these
* tests is object identity, so a shared fixture would defeat them.
*/
const entry = (settingsNode?: InspectorServerSettings): ServerEntry => ({
id: "A",
name: "PlotRocket",
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
settings: settingsNode,
});
/**
* Hands the hook's API back to the test. The hook holds a ref, so the value has
* to be read from inside a rendered component.
*/
function harness(): LastPersistedSettings {
let api: LastPersistedSettings | undefined;
function Probe() {
api = useLastPersistedSettings();
return null;
}
renderWithMantine(<Probe />);
if (!api) throw new Error("hook did not render");
return api;
}
describe("useLastPersistedSettings", () => {
it("falls back to the caller's value when no write has been recorded", () => {
const { resolve } = harness();
const fallback = settings({ paginatedLists: true });
expect(resolve("A", entry(), fallback)).toBe(fallback);
});
it("returns the recorded write while the entry has not been re-read", () => {
const { record, resolve } = harness();
const seen = entry();
const written = settings({ paginatedLists: true });
record("A", written, seen);
expect(resolve("A", seen, settings())).toBe(written);
});
it("ignores a record made for a different server", () => {
const { record, resolve } = harness();
const seen = entry();
record("A", settings({ paginatedLists: true }), seen);
const fallback = settings();
expect(resolve("B", seen, fallback)).toBe(fallback);
});
it("stops trusting the record once a fresh list read replaces the entry", () => {
const { record, resolve } = harness();
record("A", settings({ paginatedLists: true }), entry());
// A successful `GET /api/servers` rebuilds the list, so the entry is a new
// object even when its values are unchanged. That is the signal the list
// has caught up, and it is authoritative again from here.
const fresh = settings();
expect(resolve("A", entry(fresh), fresh)).toBe(fresh);
});
it("keeps only the most recent write", () => {
const { record, resolve } = harness();
const seen = entry();
record("A", settings({ paginatedLists: true }), seen);
const later = settings({ autoRefreshOnListChanged: true });
record("A", later, seen);
expect(resolve("A", seen, settings())).toBe(later);
});
});

View file

@ -0,0 +1,96 @@
import { useCallback, useRef } from "react";
import type {
InspectorServerSettings,
ServerEntry,
} from "@inspector/core/mcp/types.js";
/**
* Remembers the last server-settings write that is known to have reached disk,
* so a *later* failed write can be rolled back to what is actually persisted
* rather than to a `servers` entry that has gone stale (#2089).
*
* The `servers` list only advances when a `GET /api/servers` succeeds. Once a
* list read fails, every entry in it keeps describing disk *as of the last
* successful read* — while writes issued since then have changed disk. A
* rollback that reads its baseline from such an entry reverts to a value
* nothing on disk holds, and the UI contradicts the file until the next
* successful read. The reproduction is two toggles: the first write lands but
* its list reload fails, the second write fails outright and reverts to the
* pre-first-toggle value.
*
* The record is not trusted indefinitely. It is paired with the `servers` entry
* the write was derived from, and a successful list read replaces that entry
* (the list is re-parsed from the response, so a fresh read always yields fresh
* objects) — which is the signal that the list has caught up with, or been
* overtaken by (an edit made outside the Inspector), our write. From there the
* entry is authoritative again and the record is ignored. The *entry* is the
* token rather than its `settings` node because a server that has never been
* configured carries no `settings` at all, and `undefined` cannot distinguish
* a re-read from a stale one.
*
* Kept in a ref rather than state deliberately: nothing renders from it, it is
* read only inside callbacks at the moment a write is issued, and holding it in
* state would re-render the whole composition root on every settings save.
*/
export interface LastPersistedSettings {
/**
* Record a settings write that reached disk. `entry` is the `servers` entry
* the write was derived from.
*/
record: (
serverId: string,
written: InspectorServerSettings,
entry: ServerEntry,
) => void;
/**
* The best available answer to "what is on disk for this server". Returns the
* recorded write while `entry` is still the object the write was derived
* from, and `fallback` (the caller's read of the entry) once a fresh list
* read has replaced it, or when no write has landed for that server this
* session.
*/
resolve: (
serverId: string,
entry: ServerEntry,
fallback: InspectorServerSettings,
) => InspectorServerSettings;
}
interface WriteRecord {
serverId: string;
written: InspectorServerSettings;
entry: ServerEntry;
}
export function useLastPersistedSettings(): LastPersistedSettings {
const recordRef = useRef<WriteRecord | null>(null);
const record = useCallback(
(
serverId: string,
written: InspectorServerSettings,
entry: ServerEntry,
) => {
recordRef.current = { serverId, written, entry };
},
[],
);
const resolve = useCallback(
(
serverId: string,
entry: ServerEntry,
fallback: InspectorServerSettings,
): InspectorServerSettings => {
const current = recordRef.current;
if (!current || current.serverId !== serverId) return fallback;
// Identity, not deep equality: the question is whether the list has been
// re-read since the write, not whether the values happen to match.
if (current.entry !== entry) return fallback;
return current.written;
},
[],
);
return { record, resolve };
}