mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
fix(dashboard): drain midflight param-filter drafts (#8910)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user