fix(dashboard): drain midflight param-filter drafts (#8910)

This commit is contained in:
千乘妍 (Xiaoyaner)
2026-07-30 23:58:05 +08:00
parent e26fc0a774
commit 4f97f84dea
3 changed files with 235 additions and 4 deletions

View File

@@ -358,13 +358,25 @@ export default function ModelCompatPopover({
};
try {
const failures: string[] = [];
for (const key of Array.from(paramDraftsRef.current.keys())) {
if (!(await saveDraftForTarget(key))) failures.push(key);
// Do not snapshot the keys once: a second target can become dirty while an earlier target's
// PUT is in flight. Keep selecting live drafts until only revisions that already failed in
// this drain remain. Remember the failed object (not just its key), so a newer edit for that
// target that lands while another request is pending still gets one save attempt.
const failedDrafts = new Map<string, ParamFilterDraft>();
while (true) {
const next = Array.from(paramDraftsRef.current.entries()).find(
([key, draft]) => failedDrafts.get(key) !== draft
);
if (!next) break;
const [key] = next;
if (!(await saveDraftForTarget(key))) {
const failedDraft = paramDraftsRef.current.get(key);
if (failedDraft) failedDrafts.set(key, failedDraft);
}
}
// The indicator tracks unsaved work across ALL targets: a successful write for the target
// now on screen must not signal "saved" while another target's draft is still owed a write.
if (mountedRef.current) setParamSaveFailed(failures.length > 0);
if (mountedRef.current) setParamSaveFailed(paramDraftsRef.current.size > 0);
} finally {
paramSavingRef.current = false;
if (mountedRef.current) setParamSaving(false);

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("O5 preserves a new target edited while the older target save is in flight", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
expect(blockInput().value).toBe("");
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
expect(document.querySelector('[role="alert"]')).toBeNull();
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});

View File

@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("O5 does not lose the new target when unmounted during the older target save", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
// Every close/unmount save is rejected while model-a owns paramSavingRef. The active save took
// its key snapshot before model-b existed, so model-b has no later save scheduled.
act(() => root.unmount());
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
console.log("O5 server after unmount:", JSON.stringify(server.models), "PUTs:", putCount);
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
document.body.innerHTML = "";
vi.unstubAllGlobals();
});