Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/7889-quota-cutoff-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(dashboard): Provider Quota 'Edit cutoffs' input no longer resets while typing on unrelated parent re-renders (#7889)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Modal from "@/shared/components/Modal";
import Button from "@/shared/components/Button";
Expand All @@ -20,6 +20,17 @@ interface QuotaCutoffModalProps {
connectionName: string;
/** Used in the modal title for context (e.g. "(codex)"). */
provider: string;
/**
* Stable identifier for the connection this modal is editing (e.g. its DB
* id). Used — together with `isOpen` — to decide when to reseed `drafts`
* from `current`/`windows`, instead of depending on those props' object
* identity directly. `windows`/`current` are frequently rebuilt (e.g. a
* `.map()` in the parent, or a polling refresh) with new array/object
* references on every parent render even when nothing about this
* connection actually changed, which would otherwise re-run the seeding
* effect mid-edit and clobber whatever the operator is typing.
*/
connectionId: string | number;
/**
* Windows this connection exposes — discovered from its live quota cache
* so the modal works for any provider with usage data, not just providers
Expand All @@ -46,6 +57,7 @@ export default function QuotaCutoffModal({
onClose,
connectionName,
provider,
connectionId,
windows,
current,
providerDefaults,
Expand All @@ -60,17 +72,32 @@ export default function QuotaCutoffModal({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

// Reset drafts whenever the modal opens against a new connection.
// `windows`/`current` are read through refs (kept fresh below) rather than
// as effect dependencies: the parent frequently hands them new array/object
// identities on unrelated re-renders (e.g. a `.map()` rebuild or a polling
// refresh) even when nothing about this connection changed. Depending on
// them directly would re-seed `drafts` mid-edit and clobber the operator's
// in-progress typing. Reset instead only on the real "opened against a
// (possibly new) connection" signal: `isOpen`/`connectionId`.
const windowsRef = useRef(windows);
const currentRef = useRef(current);
useEffect(() => {
windowsRef.current = windows;
currentRef.current = current;
});

// Reset drafts whenever the modal opens (or is opened against a different
// connection), never merely because `windows`/`current` got a new identity.
useEffect(() => {
if (!isOpen) return;
const initial: Record<string, string> = {};
for (const w of windows) {
const persisted = current?.[w.key];
for (const w of windowsRef.current) {
const persisted = currentRef.current?.[w.key];
initial[w.key] = typeof persisted === "number" ? String(persisted) : "";
}
setDrafts(initial);
setError(null);
}, [isOpen, windows, current]);
}, [isOpen, connectionId]);

const resolveDefaultFor = (windowKey: string): number =>
typeof providerDefaults[windowKey] === "number"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,7 @@ export default function ProviderLimits({
) || cutoffModalConn.provider
}
provider={cutoffModalConn.provider}
connectionId={cutoffModalConn.id}
windows={cutoffModalWindows.map((q: any) => ({
key: q.name,
displayName: q.displayName || formatQuotaLabel(q.name),
Expand Down
165 changes: 165 additions & 0 deletions tests/unit/ui/issue-7889-quota-cutoff-input-persists.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @vitest-environment jsdom
//
// #7889 — Provider Quota → "Edit cutoffs": the cutoff input reset to the
// default (or cleared) while the operator was still typing. Root cause:
// QuotaCutoffModal seeded `drafts` from a `useEffect` keyed on
// `[isOpen, windows, current]`. `windows` (array) and `current` (object) are
// props whose *identity* changes on every parent re-render (e.g. a `.map()`
// rebuild, or a polling refresh) even when the underlying connection/values
// are unchanged. React's dependency comparison is by reference, so any
// unrelated parent re-render re-ran the effect and overwrote the operator's
// in-progress draft with the last persisted value.
//
// Fix: seed `drafts` only on the real "opened against a (possibly new)
// connection" signal — `[isOpen, connectionId]` — while still reading the
// latest `windows`/`current` via refs.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, afterEach } from "vitest";

vi.mock("next-intl", () => ({
// `translateUsageOrFallback` checks `t.has(key)` first and falls back to
// the caller-supplied fallback string when it's missing — so a translator
// that always reports "no key" makes every `tr(key, fallback, values)`
// call resolve to the plain `fallback` string, never touching `values`.
useTranslations: () => Object.assign((key: string) => key, { has: () => false }),
}));

(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

const { default: QuotaCutoffModal } = await import(
"../../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal"
);

const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];

function renderModal(el: HTMLDivElement, props: Record<string, unknown>) {
const root = createRoot(el);
act(() => {
root.render(
<QuotaCutoffModal
isOpen
onClose={() => {}}
connectionName="Test Connection"
provider="codex"
connectionId="conn-1"
providerDefaults={{}}
globalDefaultPercent={2}
onSave={async () => undefined}
{...(props as any)}
/>
);
});
containers.push({ root, el });
return root;
}

function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
act(() => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}

afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
});

const windows = [{ key: "primary", displayName: "Primary" }];

describe("QuotaCutoffModal — draft input survives unrelated parent re-renders (#7889)", () => {
it("keeps the typed value when the parent re-renders with new (but equivalent) windows/current identities", () => {
const el = document.createElement("div");
document.body.appendChild(el);
const root = renderModal(el, {
windows,
current: { primary: 5 },
});

const input = el.querySelector<HTMLInputElement>('input[type="number"]')!;
expect(input).toBeTruthy();
setInputValue(input, "10");
expect(input.value).toBe("10");

// Simulate an unrelated parent re-render: same connection, same logical
// values, but BRAND NEW array/object references (e.g. `.map()` rebuild
// or a polling refresh) — connectionId is unchanged.
act(() => {
root.render(
<QuotaCutoffModal
isOpen
onClose={() => {}}
connectionName="Test Connection"
provider="codex"
connectionId="conn-1"
windows={windows.map((w) => ({ ...w }))}
current={{ primary: 5 }}
providerDefaults={{}}
globalDefaultPercent={2}
onSave={async () => undefined}
/>
);
});

// RED on pre-fix code: the seeding effect re-ran (new windows/current
// identity) and clobbered the draft back to the persisted value "5".
expect(input.value).toBe("10");
});

it("resets drafts when reopened against a different connection", () => {
const el = document.createElement("div");
document.body.appendChild(el);
const root = renderModal(el, {
windows,
current: { primary: 5 },
});

const input = el.querySelector<HTMLInputElement>('input[type="number"]')!;
setInputValue(input, "10");
expect(input.value).toBe("10");

// Close, then reopen against a DIFFERENT connection with a different
// persisted value — this must reset the draft.
act(() => {
root.render(
<QuotaCutoffModal
isOpen={false}
onClose={() => {}}
connectionName="Test Connection"
provider="codex"
connectionId="conn-1"
windows={windows}
current={{ primary: 5 }}
providerDefaults={{}}
globalDefaultPercent={2}
onSave={async () => undefined}
/>
);
});
act(() => {
root.render(
<QuotaCutoffModal
isOpen
onClose={() => {}}
connectionName="Other Connection"
provider="codex"
connectionId="conn-2"
windows={windows}
current={{ primary: 42 }}
providerDefaults={{}}
globalDefaultPercent={2}
onSave={async () => undefined}
/>
);
});

const reopenedInput = el.querySelector<HTMLInputElement>('input[type="number"]')!;
expect(reopenedInput.value).toBe("42");
});
});
Loading