Compare commits

...

1 Commits

Author SHA1 Message Date
Jamison Lahman
cd02b99d5e chore(fe): replace lodash dep with internal functions
nit

fix(fe): replace JSON.stringify equality with deepEqual utility

JSON.stringify is order-sensitive and not a safe substitute for deep
equality comparison. Adds a small deepEqual helper and uses it in
svc.ts to avoid spurious change detection when key order differs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

nit

fix(fe): harden deepEqual with key presence and array-vs-object guards

- Add hasOwnProperty check so {x: undefined} vs {y: undefined} with
  matching key counts can't falsely match via undefined property access.
- Add Array.isArray(b) guard in the object branch so plain objects
  can't match arrays (e.g. {} vs [] or {0:1} vs [1]).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:47:24 -07:00
11 changed files with 188 additions and 17 deletions

7
web/package-lock.json generated
View File

@@ -55,7 +55,6 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.38",
"linguist-languages": "^9.3.1",
"lodash": "^4.17.23",
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
@@ -108,7 +107,6 @@
"@types/hast": "^3.0.4",
"@types/jest": "^29.5.14",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.20",
"@types/node": "18.15.11",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
@@ -6819,11 +6817,6 @@
"version": "0.16.7",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"license": "MIT",

View File

@@ -73,7 +73,6 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.38",
"linguist-languages": "^9.3.1",
"lodash": "^4.17.23",
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
@@ -126,7 +125,6 @@
"@types/hast": "^3.0.4",
"@types/jest": "^29.5.14",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.20",
"@types/node": "18.15.11",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",

View File

@@ -0,0 +1,48 @@
import { debounce } from "@/lib/debounce";
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test("calls the function after the wait period", () => {
const fn = jest.fn();
const debounced = debounce(fn, 200);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
test("resets the timer on subsequent calls", () => {
const fn = jest.fn();
const debounced = debounce(fn, 200);
debounced();
jest.advanceTimersByTime(100);
debounced();
jest.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
test("passes arguments to the underlying function", () => {
const fn = jest.fn();
const debounced = debounce(fn, 100);
debounced("a", "b");
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledWith("a", "b");
});
test(".cancel() prevents the pending invocation", () => {
const fn = jest.fn();
const debounced = debounce(fn, 200);
debounced();
debounced.cancel();
jest.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled();
});

27
web/src/lib/debounce.ts Normal file
View File

@@ -0,0 +1,27 @@
type DebouncedFunction<T extends (...args: never[]) => unknown> = ((
...args: Parameters<T>
) => void) & { cancel: () => void };
export function debounce<T extends (...args: never[]) => unknown>(
fn: T,
wait: number
): DebouncedFunction<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: Parameters<T>) => {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
fn(...args);
}, wait);
};
debounced.cancel = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debounced;
}

View File

@@ -0,0 +1,61 @@
import { deepEqual } from "./deepEqual";
describe("deepEqual", () => {
it("returns true for identical primitives", () => {
expect(deepEqual(1, 1)).toBe(true);
expect(deepEqual("a", "a")).toBe(true);
expect(deepEqual(true, true)).toBe(true);
expect(deepEqual(null, null)).toBe(true);
expect(deepEqual(undefined, undefined)).toBe(true);
});
it("returns false for different primitives", () => {
expect(deepEqual(1, 2)).toBe(false);
expect(deepEqual("a", "b")).toBe(false);
expect(deepEqual(null, undefined)).toBe(false);
});
it("compares objects regardless of key order", () => {
expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true);
});
it("detects different object values", () => {
expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false);
});
it("detects different object keys", () => {
expect(deepEqual({ a: 1 }, { b: 1 })).toBe(false);
expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
});
it("detects different keys even when values are undefined", () => {
expect(deepEqual({ x: undefined }, { y: undefined })).toBe(false);
});
it("compares nested objects", () => {
expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(
true
);
expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } })).toBe(
false
);
});
it("compares arrays", () => {
expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false);
expect(deepEqual([1, 2, 3], [1, 3, 2])).toBe(false);
});
it("handles mixed types", () => {
expect(deepEqual({ a: 1 }, [1])).toBe(false);
expect(deepEqual(1, "1")).toBe(false);
expect(deepEqual(null, {})).toBe(false);
});
it("distinguishes objects from arrays in both directions", () => {
expect(deepEqual({}, [])).toBe(false);
expect(deepEqual([], {})).toBe(false);
expect(deepEqual({ 0: 1 }, [1])).toBe(false);
});
});

27
web/src/lib/deepEqual.ts Normal file
View File

@@ -0,0 +1,27 @@
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
return a.every((val, i) => deepEqual(val, b[i]));
}
if (typeof a === "object") {
if (Array.isArray(b)) return false;
const keysA = Object.keys(a as Record<string, unknown>);
const keysB = Object.keys(b as Record<string, unknown>);
if (keysA.length !== keysB.length) return false;
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
}
return false;
}

View File

@@ -19,7 +19,7 @@ import {
updateCustomTool,
} from "@/lib/tools/openApiService";
import ToolItem from "@/sections/actions/ToolItem";
import debounce from "lodash/debounce";
import { debounce } from "@/lib/debounce";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
import { useModal } from "@/refresh-components/contexts/ModalContext";
import { Formik, Form, useFormikContext } from "formik";

View File

@@ -35,7 +35,7 @@ import {
LLMConfigurationModalWrapper,
} from "@/sections/modals/llmConfig/shared";
import { fetchModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { debounce } from "@/lib/debounce";
import { toast } from "@/hooks/useToast";
const DEFAULT_API_BASE = "http://localhost:1234";

View File

@@ -33,7 +33,7 @@ import {
LLMConfigurationModalWrapper,
} from "@/sections/modals/llmConfig/shared";
import { fetchOllamaModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { debounce } from "@/lib/debounce";
import Tabs from "@/refresh-components/Tabs";
import { Card } from "@opal/components";
import { toast } from "@/hooks/useToast";

View File

@@ -3,13 +3,13 @@ import {
LLMProviderView,
ModelConfiguration,
} from "@/interfaces/llm";
import { deepEqual } from "@/lib/deepEqual";
import {
LLM_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { toast } from "@/hooks/useToast";
import isEqual from "lodash/isEqual";
import { parseAzureTargetUri } from "@/lib/azureTargetUri";
import {
track,
@@ -94,7 +94,7 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
);
}
const customConfigChanged = !isEqual(
const customConfigChanged = !deepEqual(
values.custom_config,
initialValues.custom_config
);
@@ -115,7 +115,7 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
};
// Test the configuration
if (!isEqual(finalValues, initialValues)) {
if (!deepEqual(finalValues, initialValues)) {
setIsTesting(true);
const response = await fetch("/api/admin/llm/test", {

View File

@@ -1,4 +1,3 @@
var merge = require("lodash/merge");
var path = require("path");
var fs = require("fs");
var { createRequire } = require("module");
@@ -23,5 +22,23 @@ if (fs.existsSync(customThemePath)) {
customThemes = dynamicRequire(customThemePath);
}
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === "object" &&
!Array.isArray(source[key])
) {
result[key] = deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/** @type {import('tailwindcss').Config} */
module.exports = customThemes ? merge(baseThemes, customThemes) : baseThemes;
module.exports = customThemes
? deepMerge(baseThemes, customThemes)
: baseThemes;