diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 9ba2c98c2c9..fe0bc213aff 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -47,6 +47,7 @@ "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", + "usehooks-ts": "^3.1.0", "zod": "^3.25.61" }, "devDependencies": { diff --git a/apps/web-evals/src/app/runs/[id]/run.tsx b/apps/web-evals/src/app/runs/[id]/run.tsx index badd77741e0..cfbe4ba5f03 100644 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ b/apps/web-evals/src/app/runs/[id]/run.tsx @@ -3,11 +3,13 @@ import { useMemo, useState, useCallback, useEffect, Fragment } from "react" import { toast } from "sonner" import { LoaderCircle, FileText, Copy, Check, StopCircle, List, Layers } from "lucide-react" +import { useLocalStorage } from "usehooks-ts" import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals" import type { ToolName } from "@roo-code/types" import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters" +import { deserializeBoolean } from "@/lib/storage" import { useRunStatus } from "@/hooks/use-run-status" import { killRun } from "@/actions/runs" import { @@ -253,19 +255,12 @@ export function Run({ run }: { run: Run }) { const [copied, setCopied] = useState(false) const [showKillDialog, setShowKillDialog] = useState(false) const [isKilling, setIsKilling] = useState(false) - const [groupByStatus, setGroupByStatus] = useState(() => { - // Initialize from localStorage if available (client-side only) - if (typeof window !== "undefined") { - const stored = localStorage.getItem("evals-group-by-status") - return stored === "true" - } - return false - }) - // Persist groupByStatus to localStorage - useEffect(() => { - localStorage.setItem("evals-group-by-status", String(groupByStatus)) - }, [groupByStatus]) + const [groupByStatus, setGroupByStatus] = useLocalStorage("evals-group-by-status", false, { + serializer: (value: boolean) => String(value), + deserializer: deserializeBoolean, + initializeWithValue: false, + }) // Determine if run is still active (has heartbeat or runners) const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0)) diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index cea15c6ddd8..55bcd81d11f 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" +import { useLocalStorage } from "usehooks-ts" import { X, Rocket, @@ -47,6 +48,7 @@ import { ITERATIONS_DEFAULT, } from "@/lib/schemas" import { cn } from "@/lib/utils" +import { deserializeEnum, deserializeNumber, deserializeStringArray } from "@/lib/storage" import { loadRooLastModelSelection, saveRooLastModelSelection } from "@/lib/roo-last-model-selection" import { normalizeCreateRunForSubmit } from "@/lib/normalize-create-run" @@ -104,6 +106,8 @@ type ConfigSelection = { popoverOpen: boolean } +const SUITE_VALUES: ReadonlySet<"full" | "partial"> = new Set(["full", "partial"]) + export function NewRun() { const router = useRouter() const modelSelectionsByProviderRef = useRef>({}) @@ -115,6 +119,40 @@ export function NewRun() { const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20) const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds + const [savedConcurrency, setSavedConcurrency] = useLocalStorage("evals-concurrency", CONCURRENCY_DEFAULT, { + serializer: (value: number) => String(value), + deserializer: (raw: string) => deserializeNumber(raw) ?? CONCURRENCY_DEFAULT, + initializeWithValue: false, + }) + const [savedTimeout, setSavedTimeout] = useLocalStorage("evals-timeout", TIMEOUT_DEFAULT, { + serializer: (value: number) => String(value), + deserializer: (raw: string) => deserializeNumber(raw) ?? TIMEOUT_DEFAULT, + initializeWithValue: false, + }) + const [savedCommandTimeout, setSavedCommandTimeout] = useLocalStorage( + "evals-command-execution-timeout", + 20, + { + serializer: (value: number) => String(value), + deserializer: (raw: string) => deserializeNumber(raw) ?? 20, + initializeWithValue: false, + }, + ) + const [savedShellTimeout, setSavedShellTimeout] = useLocalStorage("evals-shell-integration-timeout", 30, { + serializer: (value: number) => String(value), + deserializer: (raw: string) => deserializeNumber(raw) ?? 30, + initializeWithValue: false, + }) + const [savedSuite, setSavedSuite] = useLocalStorage<"full" | "partial">("evals-suite", "full", { + serializer: (value: "full" | "partial") => value, + deserializer: (raw: string) => deserializeEnum(raw, SUITE_VALUES, "full"), + initializeWithValue: false, + }) + const [savedExercises, setSavedExercises] = useLocalStorage("evals-exercises", [], { + deserializer: deserializeStringArray, + initializeWithValue: false, + }) + const [modelSelections, setModelSelections] = useState([ { id: crypto.randomUUID(), model: "", popoverOpen: false }, ]) @@ -188,66 +226,30 @@ export function NewRun() { register("exercises") }, [register]) - // Load settings from localStorage on mount + // Sync persisted settings into the form/state (SSR-safe) useEffect(() => { - const savedConcurrency = localStorage.getItem("evals-concurrency") - - if (savedConcurrency) { - const parsed = parseInt(savedConcurrency, 10) - - if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) { - setValue("concurrency", parsed) - } + if (savedConcurrency >= CONCURRENCY_MIN && savedConcurrency <= CONCURRENCY_MAX) { + setValue("concurrency", savedConcurrency) } - - const savedTimeout = localStorage.getItem("evals-timeout") - - if (savedTimeout) { - const parsed = parseInt(savedTimeout, 10) - - if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) { - setValue("timeout", parsed) - } + if (savedTimeout >= TIMEOUT_MIN && savedTimeout <= TIMEOUT_MAX) { + setValue("timeout", savedTimeout) } - - const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout") - - if (savedCommandTimeout) { - const parsed = parseInt(savedCommandTimeout, 10) - - if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) { - setCommandExecutionTimeout(parsed) - } + if (savedCommandTimeout >= 20 && savedCommandTimeout <= 60) { + setCommandExecutionTimeout(savedCommandTimeout) } - - const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout") - - if (savedShellTimeout) { - const parsed = parseInt(savedShellTimeout, 10) - - if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) { - setTerminalShellIntegrationTimeout(parsed) - } + if (savedShellTimeout >= 30 && savedShellTimeout <= 60) { + setTerminalShellIntegrationTimeout(savedShellTimeout) } - const savedSuite = localStorage.getItem("evals-suite") - + setValue("suite", savedSuite) if (savedSuite === "partial") { - setValue("suite", "partial") - const savedExercises = localStorage.getItem("evals-exercises") - if (savedExercises) { - try { - const parsed = JSON.parse(savedExercises) as string[] - if (Array.isArray(parsed)) { - setSelectedExercises(parsed) - setValue("exercises", parsed) - } - } catch { - // Invalid JSON, ignore. - } - } + setSelectedExercises(savedExercises) + setValue("exercises", savedExercises) + } else { + setSelectedExercises([]) + setValue("exercises", []) } - }, [setValue]) + }, [savedConcurrency, savedTimeout, savedCommandTimeout, savedShellTimeout, savedSuite, savedExercises, setValue]) // Track previous provider to detect switches const [prevProvider, setPrevProvider] = useState(provider) @@ -344,9 +346,9 @@ export function NewRun() { setSelectedExercises(newSelected) setValue("exercises", newSelected) - localStorage.setItem("evals-exercises", JSON.stringify(newSelected)) + setSavedExercises(newSelected) }, - [getExercisesForLanguage, selectedExercises, setValue], + [getExercisesForLanguage, selectedExercises, setSavedExercises, setValue], ) const isLanguageSelected = useCallback( @@ -863,12 +865,13 @@ export function NewRun() { { - setValue("suite", value as "full" | "partial") - localStorage.setItem("evals-suite", value) - if (value === "full") { + const next = value === "partial" ? "partial" : "full" + setValue("suite", next) + setSavedSuite(next) + if (next === "full") { setSelectedExercises([]) setValue("exercises", []) - localStorage.removeItem("evals-exercises") + setSavedExercises([]) } }}> @@ -905,7 +908,7 @@ export function NewRun() { onValueChange={(value) => { setSelectedExercises(value) setValue("exercises", value) - localStorage.setItem("evals-exercises", JSON.stringify(value)) + setSavedExercises(value) }} placeholder="Select" variant="inverted" @@ -934,7 +937,7 @@ export function NewRun() { step={1} onValueChange={(value) => { field.onChange(value[0]) - localStorage.setItem("evals-concurrency", String(value[0])) + setSavedConcurrency(value[0] ?? CONCURRENCY_DEFAULT) }} />
{field.value}
@@ -960,7 +963,7 @@ export function NewRun() { step={1} onValueChange={(value) => { field.onChange(value[0]) - localStorage.setItem("evals-timeout", String(value[0])) + setSavedTimeout(value[0] ?? TIMEOUT_DEFAULT) }} />
{field.value}
@@ -1024,7 +1027,7 @@ export function NewRun() { onValueChange={([value]) => { if (value !== undefined) { setCommandExecutionTimeout(value) - localStorage.setItem("evals-command-execution-timeout", String(value)) + setSavedCommandTimeout(value) } }} /> @@ -1056,7 +1059,7 @@ export function NewRun() { onValueChange={([value]) => { if (value !== undefined) { setTerminalShellIntegrationTimeout(value) - localStorage.setItem("evals-shell-integration-timeout", String(value)) + setSavedShellTimeout(value) } }} /> diff --git a/apps/web-evals/src/components/home/run.tsx b/apps/web-evals/src/components/home/run.tsx index 379daf48a40..067bf3c7f68 100644 --- a/apps/web-evals/src/components/home/run.tsx +++ b/apps/web-evals/src/components/home/run.tsx @@ -44,13 +44,7 @@ import { ScrollArea, } from "@/components/ui" -// Tool group type (same as in runs.tsx) -type ToolGroup = { - id: string - name: string - icon: string - tools: string[] -} +import type { ToolGroup } from "@/lib/tool-groups" type RunProps = { run: EvalsRun diff --git a/apps/web-evals/src/components/home/runs.tsx b/apps/web-evals/src/components/home/runs.tsx index 0ac333f2cea..83b1bf1207b 100644 --- a/apps/web-evals/src/components/home/runs.tsx +++ b/apps/web-evals/src/components/home/runs.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, memo } from "react" import { useRouter } from "next/navigation" +import { useLocalStorage } from "usehooks-ts" import { ArrowDown, ArrowUp, @@ -88,6 +89,8 @@ import { TooltipTrigger, } from "@/components/ui" import { Run as Row } from "@/components/home/run" +import { deserializeEnum, deserializeStringArray } from "@/lib/storage" +import { type ToolGroup, deserializeToolGroups, serializeToolGroups } from "@/lib/tool-groups" // Available icons for tool groups const TOOL_GROUP_ICONS: { name: string; icon: LucideIcon }[] = [ @@ -124,14 +127,6 @@ const TOOL_GROUP_ICONS: { name: string; icon: LucideIcon }[] = [ { name: "tag", icon: Tag }, ] -// Tool group type -export type ToolGroup = { - id: string - name: string - icon: string - tools: string[] -} - // Helper to get icon component by name function getIconByName(name: string): LucideIcon { return TOOL_GROUP_ICONS.find((i) => i.name === name)?.icon ?? Combine @@ -256,6 +251,8 @@ type SortDirection = "asc" | "desc" type TimeframeOption = "all" | "24h" | "7d" | "30d" | "90d" +const TIMEFRAME_OPTION_VALUES: ReadonlySet = new Set(["all", "24h", "7d", "30d", "90d"]) + const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [ { value: "all", label: "All time" }, { value: "24h", label: "Last 24 hours" }, @@ -264,6 +261,10 @@ const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [ { value: "90d", label: "Last 90 days" }, ] +function deserializeTimeframe(value: string): TimeframeOption { + return deserializeEnum(value, TIMEFRAME_OPTION_VALUES, "all") +} + // LocalStorage keys const STORAGE_KEYS = { TIMEFRAME: "evals-runs-timeframe", @@ -317,35 +318,26 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) { const [sortColumn, setSortColumn] = useState("createdAt") const [sortDirection, setSortDirection] = useState("desc") - // Filter state - initialize from localStorage - const [timeframeFilter, setTimeframeFilter] = useState(() => { - if (typeof window === "undefined") return "all" - const stored = localStorage.getItem(STORAGE_KEYS.TIMEFRAME) - return (stored as TimeframeOption) || "all" + // Filter state (persistent) + const [timeframeFilter, setTimeframeFilter] = useLocalStorage(STORAGE_KEYS.TIMEFRAME, "all", { + serializer: (value: TimeframeOption) => value, // keep legacy raw-string storage + deserializer: deserializeTimeframe, + initializeWithValue: false, }) - const [modelFilter, setModelFilter] = useState(() => { - if (typeof window === "undefined") return [] - const stored = localStorage.getItem(STORAGE_KEYS.MODEL_FILTER) - return stored ? JSON.parse(stored) : [] + const [modelFilter, setModelFilter] = useLocalStorage(STORAGE_KEYS.MODEL_FILTER, [], { + deserializer: deserializeStringArray, + initializeWithValue: false, }) - const [providerFilter, setProviderFilter] = useState(() => { - if (typeof window === "undefined") return [] - const stored = localStorage.getItem(STORAGE_KEYS.PROVIDER_FILTER) - return stored ? JSON.parse(stored) : [] + const [providerFilter, setProviderFilter] = useLocalStorage(STORAGE_KEYS.PROVIDER_FILTER, [], { + deserializer: deserializeStringArray, + initializeWithValue: false, }) - // Tool groups state - initialize from localStorage - const [toolGroups, setToolGroups] = useState(() => { - if (typeof window === "undefined") return [] - const stored = localStorage.getItem(STORAGE_KEYS.TOOL_GROUPS) - if (stored) { - try { - return JSON.parse(stored) - } catch { - return [] - } - } - return [] + // Tool groups state (persistent) + const [toolGroups, setToolGroups] = useLocalStorage(STORAGE_KEYS.TOOL_GROUPS, [], { + serializer: serializeToolGroups, + deserializer: deserializeToolGroups, + initializeWithValue: false, }) // Tool group editor dialog state @@ -357,23 +349,6 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) { const [showDeleteOldConfirm, setShowDeleteOldConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - // Persist filters to localStorage - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.TIMEFRAME, timeframeFilter) - }, [timeframeFilter]) - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.MODEL_FILTER, JSON.stringify(modelFilter)) - }, [modelFilter]) - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.PROVIDER_FILTER, JSON.stringify(providerFilter)) - }, [providerFilter]) - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.TOOL_GROUPS, JSON.stringify(toolGroups)) - }, [toolGroups]) - // Count incomplete runs (runs without taskMetricsId) const incompleteRunsCount = useMemo(() => { return runs.filter((run) => run.taskMetrics === null).length @@ -618,8 +593,8 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) { const handleSaveGroup = useCallback( (group: ToolGroup) => { - setToolGroups((prev) => { - const existingIndex = prev.findIndex((g) => g.id === group.id) + setToolGroups((prev: ToolGroup[]) => { + const existingIndex = prev.findIndex((g: ToolGroup) => g.id === group.id) if (existingIndex >= 0) { // Update existing group const newGroups = [...prev] @@ -632,13 +607,16 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) { }) toast.success(editingGroup ? "Group updated" : "Group created") }, - [editingGroup], + [editingGroup, setToolGroups], ) - const handleDeleteGroup = useCallback((groupId: string) => { - setToolGroups((prev) => prev.filter((g) => g.id !== groupId)) - toast.success("Group deleted") - }, []) + const handleDeleteGroup = useCallback( + (groupId: string) => { + setToolGroups((prev: ToolGroup[]) => prev.filter((g: ToolGroup) => g.id !== groupId)) + toast.success("Group deleted") + }, + [setToolGroups], + ) // Get available tools for group editor (tools not in other groups) const availableToolsForEditor = useMemo(() => { @@ -715,7 +693,7 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) { {toolGroups.length > 0 ? ( <> - {toolGroups.map((group) => { + {toolGroups.map((group: ToolGroup) => { const IconComponent = getIconByName(group.icon) return ( Tokens {/* Tool Group Columns */} - {toolGroups.map((group) => { + {toolGroups.map((group: ToolGroup) => { const IconComponent = getIconByName(group.icon) return ( @@ -870,7 +848,7 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
{group.name}
- {group.tools.map((tool) => ( + {group.tools.map((tool: string) => (
{tool}
))}
diff --git a/apps/web-evals/src/lib/__tests__/storage.spec.ts b/apps/web-evals/src/lib/__tests__/storage.spec.ts new file mode 100644 index 00000000000..3bc414c17e5 --- /dev/null +++ b/apps/web-evals/src/lib/__tests__/storage.spec.ts @@ -0,0 +1,32 @@ +import { deserializeBoolean, deserializeEnum, deserializeNumber, deserializeStringArray } from "../storage" + +describe("storage deserializers", () => { + it("deserializeNumber supports raw numbers and JSON numbers", () => { + expect(deserializeNumber("42")).toBe(42) + expect(deserializeNumber("3.14")).toBe(3.14) + expect(deserializeNumber('"nope"')).toBeUndefined() + expect(deserializeNumber("{bad json")).toBeUndefined() + }) + + it("deserializeStringArray returns [] on invalid input", () => { + expect(deserializeStringArray("[]")).toEqual([]) + expect(deserializeStringArray('["a","b"]')).toEqual(["a", "b"]) + expect(deserializeStringArray("[1,2]")).toEqual([]) + expect(deserializeStringArray("{bad json")).toEqual([]) + }) + + it("deserializeBoolean supports legacy raw strings and JSON booleans", () => { + expect(deserializeBoolean("true")).toBe(true) + expect(deserializeBoolean("false")).toBe(false) + expect(deserializeBoolean('"true"')).toBe(false) + expect(deserializeBoolean("{bad json")).toBe(false) + }) + + it("deserializeEnum supports legacy raw strings and JSON strings", () => { + const allowed = new Set(["full", "partial"] as const) + expect(deserializeEnum("full", allowed, "full")).toBe("full") + expect(deserializeEnum('"partial"', allowed, "full")).toBe("partial") + expect(deserializeEnum("{bad json", allowed, "full")).toBe("full") + expect(deserializeEnum("other", allowed, "full")).toBe("full") + }) +}) diff --git a/apps/web-evals/src/lib/storage.ts b/apps/web-evals/src/lib/storage.ts new file mode 100644 index 00000000000..b72f32458a9 --- /dev/null +++ b/apps/web-evals/src/lib/storage.ts @@ -0,0 +1,49 @@ +export function tryParseJson(raw: string): unknown { + try { + return JSON.parse(raw) + } catch { + return undefined + } +} + +export function deserializeNumber(raw: string): number | undefined { + const parsed = tryParseJson(raw) + if (typeof parsed === "number" && Number.isFinite(parsed)) return parsed + + const trimmed = raw.trim() + if (trimmed === "" || trimmed === "null") return undefined + + // Legacy raw-string storage: accept only plain decimal numbers. + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return undefined + + const asNumber = Number(trimmed) + return Number.isFinite(asNumber) ? asNumber : undefined +} + +export function deserializeString(raw: string): string { + const parsed = tryParseJson(raw) + return typeof parsed === "string" ? parsed : raw +} + +export function deserializeStringArray(raw: string): string[] { + const parsed = tryParseJson(raw) + return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") ? parsed : [] +} + +export function deserializeBoolean(raw: string): boolean { + // Support legacy raw-string storage and default JSON serialization. + if (raw === "true") return true + if (raw === "false") return false + + const parsed = tryParseJson(raw) + return typeof parsed === "boolean" ? parsed : false +} + +export function deserializeEnum(raw: string, allowed: ReadonlySet, fallback: T): T { + if (allowed.has(raw as T)) return raw as T + + const parsed = tryParseJson(raw) + if (typeof parsed === "string" && allowed.has(parsed as T)) return parsed as T + + return fallback +} diff --git a/apps/web-evals/src/lib/tool-groups.ts b/apps/web-evals/src/lib/tool-groups.ts new file mode 100644 index 00000000000..0e7f890d338 --- /dev/null +++ b/apps/web-evals/src/lib/tool-groups.ts @@ -0,0 +1,37 @@ +import { deserializeStringArray, tryParseJson } from "@/lib/storage" + +export type ToolGroup = { + id: string + name: string + icon: string + tools: string[] +} + +function isToolGroup(value: unknown): value is ToolGroup { + if (!value || typeof value !== "object") return false + const v = value as Record + return ( + typeof v.id === "string" && + typeof v.name === "string" && + typeof v.icon === "string" && + Array.isArray(v.tools) && + v.tools.every((t) => typeof t === "string") + ) +} + +export function deserializeToolGroups(raw: string): ToolGroup[] { + // Be tolerant: default `useLocalStorage` JSON serialization will be valid JSON. + // If consumers previously wrote non-JSON, treat as empty. + const parsed = tryParseJson(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isToolGroup) +} + +export function serializeToolGroups(groups: ToolGroup[]): string { + return JSON.stringify(groups) +} + +export function deserializeToolGroupTools(raw: string): string[] { + // Useful if we ever need to read just tool arrays. + return deserializeStringArray(raw) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 983a6e97b57..f6dc5d85c38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.1.6) + usehooks-ts: + specifier: ^3.1.0 + version: 3.1.1(react@18.3.1) vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -9990,6 +9993,12 @@ packages: peerDependencies: react: '>=16.8' + usehooks-ts@3.1.1: + resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -20723,6 +20732,11 @@ snapshots: howler: 2.2.4 react: 18.3.1 + usehooks-ts@3.1.1(react@18.3.1): + dependencies: + lodash.debounce: 4.0.8 + react: 18.3.1 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {}