Skip to content

Commit a2023ca

Browse files
committed
Preserve generated UI mutation pending state
1 parent b514f1f commit a2023ca

5 files changed

Lines changed: 94 additions & 98 deletions

File tree

packages/core/execution/src/description.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ const formatDescription = (sources: readonly Source[]): string => {
7272
"- Use the discovered output shape exactly. Do not invent wrapper fields like `data.domain` or `data.items` unless the schema/sample shows them.",
7373
"- For toggles and switches, mutate with the checked value from the event instead of inverting possibly stale query data.",
7474
"- For optimistic writes, use TanStack `onMutate` / `onError` / `onSettled`: cancel the query, snapshot old data, `setQueryData`, roll back on error, then invalidate.",
75-
"- Success messages must use `mutation.variables` or neutral wording, not query-derived state that may still be stale while invalidation refetches.",
7675
"- Only hardcode small display constants like labels, colors, tab names, and chart configuration. Never embed tool response rows, API results, summaries, or dashboard data as literals in the component.",
7776
"- Always render loading and error states from `useQuery` / `useMutation`; do not replace them with hardcoded fallback data.",
7877
"- Tools: `tools.<namespace>.<tool>(args)` — call any configured API tool (never use raw `fetch`). Tool helpers: `.queryOptions(args, options)`, `.mutationOptions(options)`, `.queryKey(args)`, `.pathKey()`, and `.mutationKey()`.",

packages/hosts/mcp/src/server.test.ts

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -254,49 +254,6 @@ describe("MCP host server — client with elicitation", () => {
254254
);
255255
});
256256

257-
it("render-ui rejects stale toggle mutation state patterns", async () => {
258-
await withClient(
259-
makeStubEngine({}),
260-
APPS_ELICITATION_CAPS,
261-
async (client) => {
262-
const invertedToggle = await client.callTool({
263-
name: "render-ui",
264-
arguments: {
265-
code: [
266-
"function App() {",
267-
" const autoRenew = false;",
268-
" const mutation = useMutation(tools.vercel_api.domainsRegistrar.updateDomainAutoRenew.mutationOptions({}));",
269-
" return <Switch onCheckedChange={() => mutation.mutate({ body: { autoRenew: !autoRenew } })} />;",
270-
"}",
271-
].join("\n"),
272-
},
273-
});
274-
expect(invertedToggle.isError).toBe(true);
275-
expect(textOf(invertedToggle)).toContain(
276-
'Mutation payloads must not invert query-backed state like "!autoRenew"',
277-
);
278-
279-
const staleSuccessLabel = await client.callTool({
280-
name: "render-ui",
281-
arguments: {
282-
code: [
283-
"function App() {",
284-
" const autoRenew = false;",
285-
" const mutation = { isSuccess: true };",
286-
' return <div>{mutation.isSuccess && <span>Auto-renew {autoRenew ? "enabled" : "disabled"} successfully</span>}</div>;',
287-
"}",
288-
].join("\n"),
289-
},
290-
});
291-
expect(staleSuccessLabel.isError).toBe(true);
292-
expect(textOf(staleSuccessLabel)).toContain(
293-
'Mutation success text must not read "autoRenew"',
294-
);
295-
},
296-
{ plugins: [DYNAMIC_UI_PLUGIN] },
297-
);
298-
});
299-
300257
it("splits execution and UI rendering into separate model-facing tool descriptions", async () => {
301258
const description = [
302259
"Execute TypeScript in a sandboxed runtime.",
@@ -337,12 +294,6 @@ describe("MCP host server — client with elicitation", () => {
337294
expect(renderUi?.description).toContain("For optimistic UI, use `onMutate`");
338295
expect(renderUi?.description).toContain("Do not call API tools first");
339296
expect(renderUi?.description).toContain("Do not redeclare or destructure provided globals");
340-
expect(renderUi?.description).toContain(
341-
"server rejects toggle mutations that invert query-backed booleans",
342-
);
343-
expect(renderUi?.description).toContain(
344-
"server rejects mutation success labels that read enabled/disabled wording",
345-
);
346297
expect(renderUi?.description).toContain("server rejects obvious hardcoded live-data");
347298
expect(renderUi?.description).toContain("server rejects redeclarations");
348299
expect(renderUi?.description).toContain("- `axiom_mcp`");

packages/hosts/mcp/src/shell/hooks.ts

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
skipToken,
88
useMutation as useTanStackMutation,
99
useQuery as useTanStackQuery,
10-
useQueryClient,
10+
useQueryClient as useTanStackQueryClient,
1111
type MutationKey,
1212
type QueryClient as QueryClientType,
1313
type QueryKey,
@@ -17,17 +17,11 @@ import {
1717
type UseQueryResult,
1818
} from "@tanstack/react-query";
1919

20-
export {
21-
QueryClient,
22-
QueryClientProvider,
23-
mutationOptions,
24-
queryOptions,
25-
skipToken,
26-
useQueryClient,
27-
};
20+
export { QueryClient, QueryClientProvider, mutationOptions, queryOptions, skipToken };
2821

2922
let legacyQueryId = 0;
3023
let legacyMutationId = 0;
24+
const invalidationScopes: Array<Array<Promise<unknown>>> = [];
3125

3226
type RefetchableQuery = {
3327
refetch: () => unknown;
@@ -49,6 +43,68 @@ const nextLegacyMutationKey = (): MutationKey => [
4943
++legacyMutationId,
5044
];
5145

46+
const trackInvalidation = (promise: Promise<unknown>) => {
47+
for (const scope of invalidationScopes) {
48+
scope.push(promise);
49+
}
50+
return promise;
51+
};
52+
53+
const trackMutationCallback = async <T>(callback: () => T | Promise<T>): Promise<T> => {
54+
const scope: Array<Promise<unknown>> = [];
55+
invalidationScopes.push(scope);
56+
try {
57+
const result = await callback();
58+
await Promise.allSettled(scope);
59+
return result;
60+
} finally {
61+
invalidationScopes.pop();
62+
}
63+
};
64+
65+
const wrapQueryClient = (client: QueryClientType): QueryClientType =>
66+
new Proxy(client, {
67+
get(target, prop, receiver) {
68+
const value = Reflect.get(target, prop, receiver) as unknown;
69+
if (prop === "invalidateQueries" && typeof value === "function") {
70+
return (...args: unknown[]) =>
71+
trackInvalidation(
72+
(value as (...args: unknown[]) => Promise<unknown>).apply(target, args),
73+
);
74+
}
75+
return typeof value === "function" ? value.bind(target) : value;
76+
},
77+
}) as QueryClientType;
78+
79+
export const useQueryClient = (queryClient?: QueryClientType): QueryClientType => {
80+
const client = useTanStackQueryClient(queryClient);
81+
const wrappedRef = useRef<{ client: QueryClientType; wrapped: QueryClientType } | null>(null);
82+
83+
if (wrappedRef.current?.client !== client) {
84+
wrappedRef.current = { client, wrapped: wrapQueryClient(client) };
85+
}
86+
87+
return wrappedRef.current.wrapped;
88+
};
89+
90+
const wrapMutationOptions = <TData, TError, TVariables, TContext>(
91+
options: UseMutationOptions<TData, TError, TVariables, TContext>,
92+
): UseMutationOptions<TData, TError, TVariables, TContext> => ({
93+
...options,
94+
onSuccess: options.onSuccess
95+
? (...args: Parameters<NonNullable<typeof options.onSuccess>>) =>
96+
trackMutationCallback(() => options.onSuccess?.(...args))
97+
: undefined,
98+
onError: options.onError
99+
? (...args: Parameters<NonNullable<typeof options.onError>>) =>
100+
trackMutationCallback(() => options.onError?.(...args))
101+
: undefined,
102+
onSettled: options.onSettled
103+
? (...args: Parameters<NonNullable<typeof options.onSettled>>) =>
104+
trackMutationCallback(() => options.onSettled?.(...args))
105+
: undefined,
106+
});
107+
52108
/**
53109
* TanStack Query's `useQuery`, plus compatibility for the original generated
54110
* UI shorthand: `useQuery(() => tools.namespace.tool(args))`.
@@ -123,14 +179,14 @@ export function useMutation(
123179
...tanstackOptions,
124180
mutationFn: optionsOrFn as (input: unknown) => Promise<unknown>,
125181
onSuccess: async (data, variables, context, mutationContext) => {
126-
await onSuccess?.(data, variables, context, mutationContext);
182+
await trackMutationCallback(() => onSuccess?.(data, variables, context, mutationContext));
127183
await Promise.all(invalidates?.map((query) => query.refetch()) ?? []);
128184
},
129185
});
130186
}
131187

132188
return useTanStackMutation(
133-
optionsOrFn as UseMutationOptions<unknown, Error, unknown, unknown>,
189+
wrapMutationOptions(optionsOrFn as UseMutationOptions<unknown, Error, unknown, unknown>),
134190
optionsOrQueryClient as QueryClientType | undefined,
135191
);
136192
}

packages/hosts/mcp/src/shell/mcp-app.browser.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,11 @@ function App() {
169169
const domainQuery = useQuery(tools.inventory.domains.getDomain.queryOptions(domainArgs));
170170
const updateAutoRenew = useMutation(
171171
tools.inventory.domains.updateDomainAutoRenew.mutationOptions({
172-
onSuccess: () =>
172+
onSuccess: () => {
173173
queryClient.invalidateQueries({
174174
queryKey: tools.inventory.domains.getDomain.queryKey(domainArgs),
175-
}),
175+
});
176+
},
176177
})
177178
);
178179
@@ -184,6 +185,12 @@ function App() {
184185
<div id="auto-renew-state">
185186
{domainQuery.isLoading ? "loading" : String(autoRenew)}
186187
</div>
188+
<div id="auto-renew-pending">{String(updateAutoRenew.isPending)}</div>
189+
<div id="auto-renew-success">
190+
{updateAutoRenew.isSuccess
191+
? "Auto-renew " + (autoRenew ? "enabled" : "disabled") + " successfully"
192+
: ""}
193+
</div>
187194
<Button
188195
id="auto-renew-toggle"
189196
disabled={domainQuery.isLoading || updateAutoRenew.isPending}
@@ -337,6 +344,7 @@ const startOpenApiServer = (): Promise<OpenApiServer> =>
337344
new Promise((resolveServer, rejectServer) => {
338345
let baseUrl = "";
339346
let domainAutoRenew = false;
347+
let nextDomainGetDelayMs = 0;
340348
const postRequests: string[] = [];
341349

342350
const server: Server = createServer(async (request, response) => {
@@ -501,6 +509,11 @@ const startOpenApiServer = (): Promise<OpenApiServer> =>
501509
if (request.method === "GET" && request.url?.startsWith("/domains/")) {
502510
const domain = decodeURIComponent(request.url.slice("/domains/".length));
503511
if (!domain.includes("/")) {
512+
const delayMs = nextDomainGetDelayMs;
513+
nextDomainGetDelayMs = 0;
514+
if (delayMs > 0) {
515+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
516+
}
504517
response.statusCode = 200;
505518
response.setHeader("content-type", "application/json");
506519
response.end(JSON.stringify({ domain, renew: domainAutoRenew }));
@@ -533,6 +546,7 @@ const startOpenApiServer = (): Promise<OpenApiServer> =>
533546
postRequests.push(body);
534547
const parsed = JSON.parse(body) as { autoRenew?: unknown };
535548
domainAutoRenew = parsed.autoRenew === true;
549+
nextDomainGetDelayMs = 300;
536550
const domainPath = request.url.slice(
537551
"/domains/".length,
538552
request.url.length - "/auto-renew".length,
@@ -1030,11 +1044,22 @@ describe("MCP app generated UI browser isolation", () => {
10301044
expect(openApiServer.postRequests).toHaveLength(initialPostCount);
10311045

10321046
await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 });
1047+
await innerFrame.waitForTimeout(100);
1048+
expect(await innerFrame.locator("#auto-renew-pending").textContent()).toBe("true");
1049+
expect(await innerFrame.locator("#auto-renew-success").textContent()).toBe("");
1050+
10331051
await innerFrame.waitForFunction(
10341052
() => document.querySelector("#auto-renew-state")?.textContent === "true",
10351053
undefined,
10361054
{ timeout: 10_000 },
10371055
);
1056+
await innerFrame.waitForFunction(
1057+
() =>
1058+
document.querySelector("#auto-renew-success")?.textContent ===
1059+
"Auto-renew enabled successfully",
1060+
undefined,
1061+
{ timeout: 10_000 },
1062+
);
10381063

10391064
expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1);
10401065
expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ autoRenew: true }));

packages/plugins/dynamic-ui/src/mcp.ts

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,6 @@ export const buildRenderUiDescription = (executeDescription: string): string =>
338338
"- Use the discovered output shape exactly. Do not invent wrapper fields like `data.domain` or `data.items` unless the schema/sample shows them.",
339339
"- For toggles and switches, mutate with the checked value from the event instead of inverting possibly stale query data.",
340340
"- For optimistic writes, use TanStack `onMutate` / `onError` / `onSettled`: cancel the query, snapshot old data, `setQueryData`, roll back on error, then invalidate.",
341-
"- Success messages must use `mutation.variables` or neutral wording, not query-derived state that may still be stale while invalidation refetches.",
342341
"- Only hardcode small display constants like labels, colors, tab names, and chart configuration. Never embed tool response rows, API results, summaries, or dashboard data as literals in the component.",
343342
"- Always render loading and error states from `useQuery` / `useMutation`; do not replace them with hardcoded fallback data.",
344343
`- shadcn/ui components available by name: ${SHADCN_COMPONENTS}`,
@@ -373,9 +372,6 @@ export const buildRenderUiDescription = (executeDescription: string): string =>
373372
"- For simple writes, invalidate with `queryClient.invalidateQueries(tools.<namespace>.<queryTool>.queryFilter(args))` in `onSuccess` or `onSettled`.",
374373
"- For toggles and switches, pass the new checked value into `mutate`: `onCheckedChange={(checked) => mutation.mutate({ body: { enabled: checked } })}`.",
375374
"- For optimistic UI, use `onMutate` to `cancelQueries`, snapshot `getQueryData`, and `setQueryData`; return the snapshot, restore it in `onError`, and invalidate in `onSettled`.",
376-
"- Do not send mutation payloads by inverting query state like `autoRenew: !autoRenew`; the cached value can be stale after an approval flow.",
377-
'- Do not show success text by combining `mutation.isSuccess` with query-derived state like `autoRenew ? "enabled" : "disabled"`. `isSuccess` becomes true before invalidated reads finish refetching.',
378-
'- For action-specific success text, read the submitted value from `mutation.variables` such as `mutation.variables?.body?.autoRenew ? "enabled" : "disabled"`, or show neutral text like `Saved successfully`.',
379375
"",
380376
"## Available UI Components",
381377
"",
@@ -391,8 +387,6 @@ export const buildRenderUiDescription = (executeDescription: string): string =>
391387
"- Keep data live by routing every API read/write through the provided `tools` proxy from TanStack Query or `run(code)`.",
392388
"- Do not redeclare or destructure provided globals. Hooks, components, icons, `tools`, and `run` are already in scope; use them directly.",
393389
"- Tool proxy helpers are TanStack-native: `.queryOptions(args, options)`, `.mutationOptions(options)`, `.queryKey(args)`, `.queryFilter(args, filters)`, `.pathKey()`, `.pathFilter(filters)`, and `.mutationKey()`.",
394-
"- The server rejects toggle mutations that invert query-backed booleans such as `autoRenew: !autoRenew`; use the checked value from the UI event.",
395-
"- The server rejects mutation success labels that read enabled/disabled wording from stale query state; use `mutation.variables` or neutral text.",
396390
"- The server rejects obvious hardcoded live-data snapshots such as `const rows = [{...}, {...}]`; regenerate with `useQuery` instead.",
397391
"- The server rejects redeclarations of provided globals such as `const { useState } = React` or `const Card = ...` before the UI reaches the iframe.",
398392
"",
@@ -416,19 +410,6 @@ const OBJECT_DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=
416410
const PROVIDED_GLOBAL_DECLARATION =
417411
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b|\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\bclass\s+([A-Za-z_$][\w$]*)\b/g;
418412

419-
const QUERY_BACKED_BOOLEAN_NAME =
420-
"(?:autoRenew|enabled|isEnabled|checked|isChecked|active|isActive|disabled|isDisabled)";
421-
422-
const MUTATION_INVERTS_QUERY_STATE = new RegExp(
423-
String.raw`\bmutate\s*\(\s*\{[\s\S]{0,1000}\b[A-Za-z_$][\w$]*\s*:\s*!\s*(${QUERY_BACKED_BOOLEAN_NAME})\b`,
424-
"i",
425-
);
426-
427-
const MUTATION_SUCCESS_USES_QUERY_STATE_LABEL = new RegExp(
428-
String.raw`\b\w+\.isSuccess\b[\s\S]{0,1200}\{\s*(${QUERY_BACKED_BOOLEAN_NAME})\s*\?\s*["'\`](?:enabled|disabled|on|off|activated|deactivated)\b`,
429-
"i",
430-
);
431-
432413
const firstDefined = (...values: Array<string | undefined>): string | undefined =>
433414
values.find((value): value is string => value !== undefined);
434415

@@ -473,22 +454,6 @@ export const validateRenderUiCode = (code: string): string | null => {
473454
}
474455
}
475456

476-
const invertedState = MUTATION_INVERTS_QUERY_STATE.exec(code)?.[1];
477-
if (invertedState) {
478-
return [
479-
`Mutation payloads must not invert query-backed state like "!${invertedState}".`,
480-
"Switch and checkbox handlers receive the next checked value; pass that value into mutate instead.",
481-
].join(" ");
482-
}
483-
484-
const staleSuccessState = MUTATION_SUCCESS_USES_QUERY_STATE_LABEL.exec(code)?.[1];
485-
if (staleSuccessState) {
486-
return [
487-
`Mutation success text must not read "${staleSuccessState}" from query state.`,
488-
"`mutation.isSuccess` becomes true before invalidated queries finish refetching; use mutation.variables for action-specific text or show neutral saved text.",
489-
].join(" ");
490-
}
491-
492457
for (const match of code.matchAll(OBJECT_ARRAY_LITERAL)) {
493458
const name = match[1];
494459
const body = match[2] ?? "";

0 commit comments

Comments
 (0)