Merge pull request #2281 from modelcontextprotocol/v2/fix/2206-duplicate-resource-uri-keys
fix: key resource list rows by position so duplicate URIs cannot collide
This commit is contained in:
commit
849eb08db0
4 changed files with 567 additions and 32 deletions
|
|
@ -452,4 +452,135 @@ describe("ResourceControls", () => {
|
|||
renderWithMantine(<ResourceControls {...baseProps} />);
|
||||
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Nothing in the protocol makes `resources/list` URIs unique, and the store
|
||||
// does not dedupe them either — `ManagedListState.applyItems` replaces the
|
||||
// list wholesale, so a repeat is what the server actually sent. Keying a row
|
||||
// on the URI alone therefore collides, which React warns about on every
|
||||
// render and which lets a filtered-out row survive reconciliation (#2206).
|
||||
describe("duplicate identifiers (#2206)", () => {
|
||||
const duplicateUriResources: Resource[] = [
|
||||
{ name: "app", title: "App First", uri: "ui://hello-world/app.html" },
|
||||
{ name: "notes", title: "Notes", uri: "file:///notes.md" },
|
||||
{ name: "app", title: "App Second", uri: "ui://hello-world/app.html" },
|
||||
];
|
||||
|
||||
const duplicateTemplates: ResourceTemplate[] = [
|
||||
{ name: "profile", title: "Profile First", uriTemplate: "file:///{id}" },
|
||||
{ name: "logs", title: "Logs", uriTemplate: "log:///{day}" },
|
||||
{ name: "profile", title: "Profile Second", uriTemplate: "file:///{id}" },
|
||||
];
|
||||
|
||||
// Same URI, different names — the shape a search can tell apart. The rows
|
||||
// display the last URI segment, so assertions count rows rather than text.
|
||||
const duplicateSubscriptions: InspectorResourceSubscription[] = [
|
||||
{
|
||||
resource: { name: "alpha", uri: "ui://hello-world/app.html" },
|
||||
lastUpdated: new Date("2026-03-17T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
resource: { name: "beta", uri: "file:///notes.md" },
|
||||
lastUpdated: new Date("2026-03-17T10:31:00Z"),
|
||||
},
|
||||
{
|
||||
resource: { name: "gamma", uri: "ui://hello-world/app.html" },
|
||||
lastUpdated: new Date("2026-03-17T10:32:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
// The console warning was the reported symptom, so assert on it directly:
|
||||
// React only emits it when two siblings share a key.
|
||||
it("renders repeated URIs without a React key collision", () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
renderWithMantine(
|
||||
<ResourceControls
|
||||
{...baseProps}
|
||||
resources={duplicateUriResources}
|
||||
templates={duplicateTemplates}
|
||||
subscriptions={duplicateSubscriptions}
|
||||
/>,
|
||||
);
|
||||
const messages = consoleError.mock.calls.map((call) =>
|
||||
call.map(String).join(" "),
|
||||
);
|
||||
expect(
|
||||
messages.filter((message) => message.includes("same key")),
|
||||
).toEqual([]);
|
||||
// Every entry the server sent is still on screen, which is the other
|
||||
// half of what the collision put at risk.
|
||||
expect(screen.getByText("App First")).toBeInTheDocument();
|
||||
expect(screen.getByText("App Second")).toBeInTheDocument();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("removes every non-matching row when resource URIs repeat", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledResourceControls resources={duplicateUriResources} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search..."), "notes");
|
||||
|
||||
expect(screen.getByText("Notes")).toBeInTheDocument();
|
||||
// Both copies are orphaned by the collision on the broken build; the
|
||||
// second is the one React reuses rather than unmounting.
|
||||
expect(screen.queryByText("App First")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("App Second")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes every non-matching row when uriTemplates repeat", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledResourceControls
|
||||
resources={[]}
|
||||
subscriptions={[]}
|
||||
templates={duplicateTemplates}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search..."), "logs");
|
||||
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Profile First")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Profile Second")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one row per subscription when their URIs repeat", () => {
|
||||
renderWithMantine(
|
||||
<ResourceControls
|
||||
{...baseProps}
|
||||
resources={[]}
|
||||
templates={[]}
|
||||
subscriptions={duplicateSubscriptions}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Subscriptions (3)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Unsubscribe" }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("removes every non-matching row when subscription URIs repeat", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledResourceControls
|
||||
resources={[]}
|
||||
templates={[]}
|
||||
subscriptions={duplicateSubscriptions}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search..."), "beta");
|
||||
|
||||
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Unsubscribe" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type ListPaginationControlsProps,
|
||||
} from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { listRowKey } from "../../../utils/listRowKey";
|
||||
import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
|
||||
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
|
||||
|
||||
|
|
@ -79,7 +80,15 @@ export interface ResourceControlsProps {
|
|||
subscriptionStreamState?: ResourceSubscriptionStreamState;
|
||||
/** Negotiated protocol era; gates the modern subscription stream chrome. */
|
||||
protocolEra?: ProtocolEra;
|
||||
/**
|
||||
* The selected resource's `uri` — the wire identity, not a row key. A
|
||||
* duplicated URI therefore highlights every row carrying it, which is
|
||||
* correct here in a way it was not for tools (#2001): a `resources/read`
|
||||
* takes the URI, so the duplicate rows all denote the same resource. Only
|
||||
* the React key needs to distinguish them (#2206).
|
||||
*/
|
||||
selectedUri?: string;
|
||||
/** As `selectedUri`, keyed on `uriTemplate`. */
|
||||
selectedTemplateUri?: string;
|
||||
// Search text + accordion open-sections are controlled by the parent (App,
|
||||
// via ResourcesScreen) so they persist across tab navigation within a live
|
||||
|
|
@ -155,24 +164,43 @@ export function ResourceControls({
|
|||
onCompactChange,
|
||||
}: ResourceControlsProps) {
|
||||
const query = searchText.toLowerCase();
|
||||
const filteredResources = resources.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
(r.title?.toLowerCase().includes(query) ?? false) ||
|
||||
r.uri.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredTemplates = templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
(t.title?.toLowerCase().includes(query) ?? false) ||
|
||||
t.uriTemplate.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredSubscriptions = subscriptions.filter(
|
||||
(s) =>
|
||||
s.resource.name.toLowerCase().includes(query) ||
|
||||
(s.resource.title?.toLowerCase().includes(query) ?? false) ||
|
||||
s.resource.uri.toLowerCase().includes(query),
|
||||
);
|
||||
// Each row carries a `listRowKey` computed from its position in the
|
||||
// *unfiltered* list, because nothing stops a server returning the same `uri`
|
||||
// or `uriTemplate` twice (#2206). Computed before filtering so the key stays
|
||||
// stable as a search narrows the view.
|
||||
const filteredResources = resources
|
||||
.map((resource, sourceIndex) => ({
|
||||
resource,
|
||||
key: listRowKey(resource.uri, sourceIndex),
|
||||
}))
|
||||
.filter(
|
||||
({ resource: r }) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
(r.title?.toLowerCase().includes(query) ?? false) ||
|
||||
r.uri.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredTemplates = templates
|
||||
.map((template, sourceIndex) => ({
|
||||
template,
|
||||
key: listRowKey(template.uriTemplate, sourceIndex),
|
||||
}))
|
||||
.filter(
|
||||
({ template: t }) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
(t.title?.toLowerCase().includes(query) ?? false) ||
|
||||
t.uriTemplate.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredSubscriptions = subscriptions
|
||||
.map((subscription, sourceIndex) => ({
|
||||
subscription,
|
||||
key: listRowKey(subscription.resource.uri, sourceIndex),
|
||||
}))
|
||||
.filter(
|
||||
({ subscription: s }) =>
|
||||
s.resource.name.toLowerCase().includes(query) ||
|
||||
(s.resource.title?.toLowerCase().includes(query) ?? false) ||
|
||||
s.resource.uri.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
// Modern-era chrome for the single `subscriptions/listen` stream (#1630):
|
||||
// a status badge in the section header (so it stays visible while the section
|
||||
|
|
@ -312,9 +340,9 @@ export function ResourceControls({
|
|||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{filteredResources.map((resource) => (
|
||||
{filteredResources.map(({ resource, key }) => (
|
||||
<ResourceListItem
|
||||
key={resource.uri}
|
||||
key={key}
|
||||
resource={resource}
|
||||
selected={resource.uri === selectedUri}
|
||||
onClick={() => {
|
||||
|
|
@ -338,9 +366,9 @@ export function ResourceControls({
|
|||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{filteredTemplates.map((template) => (
|
||||
{filteredTemplates.map(({ template, key }) => (
|
||||
<ResourceListItem
|
||||
key={template.uriTemplate}
|
||||
key={key}
|
||||
resource={template}
|
||||
selected={template.uriTemplate === selectedTemplateUri}
|
||||
onClick={() => {
|
||||
|
|
@ -381,9 +409,9 @@ export function ResourceControls({
|
|||
{NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE}
|
||||
</StreamNotice>
|
||||
)}
|
||||
{filteredSubscriptions.map((sub) => (
|
||||
{filteredSubscriptions.map(({ subscription: sub, key }) => (
|
||||
<ResourceSubscribedItem
|
||||
key={sub.resource.uri}
|
||||
key={key}
|
||||
subscription={sub}
|
||||
onUnsubscribe={() =>
|
||||
onUnsubscribeResource(sub.resource.uri)
|
||||
|
|
|
|||
|
|
@ -122,6 +122,35 @@ describe("RootsTable", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The roots list is user-maintained and nothing dedupes it, so the same URI
|
||||
// can appear twice and collide on the row key (#2206).
|
||||
it("renders both rows when a root URI repeats (#2206)", () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
renderWithMantine(
|
||||
<RootsTable
|
||||
{...baseProps}
|
||||
roots={[
|
||||
{ name: "First", uri: "file:///dupe" },
|
||||
{ name: "Second", uri: "file:///dupe" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second")).toBeInTheDocument();
|
||||
const messages = consoleError.mock.calls.map((call) =>
|
||||
call.map(String).join(" "),
|
||||
);
|
||||
expect(
|
||||
messages.filter((message) => message.includes("same key")),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the current draft values in the inputs", () => {
|
||||
renderWithMantine(
|
||||
<RootsTable
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { Root } from "@modelcontextprotocol/client";
|
||||
import { listRowKey } from "../../../utils/listRowKey";
|
||||
|
||||
export type RootDraft = { name: string; uri: string };
|
||||
|
||||
|
|
@ -62,8 +63,10 @@ export function RootsTable({
|
|||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{roots.map((root) => (
|
||||
<Table.Tr key={root.uri}>
|
||||
{/* Keyed on position as well as URI: nothing dedupes the roots
|
||||
list, so the same URI can appear twice and collide (#2206). */}
|
||||
{roots.map((root, index) => (
|
||||
<Table.Tr key={listRowKey(root.uri, index)}>
|
||||
<Table.Td>{root.name}</Table.Td>
|
||||
<Table.Td>{root.uri}</Table.Td>
|
||||
<Table.Td>
|
||||
|
|
|
|||
Loading…
Reference in a new issue