Browse Source

feat(quick-23): memoize ModelParameters and optimize internals

- Wrap ModelParameters and ParameterInput in React.memo
- Add useMemo on sortedSchema and displaySchema computations
- RAF-debounce ResizeObserver with redundancy guard
- Stabilize onChange callback (pass name as prop, not closure)
- Eliminates re-renders during canvas pan/zoom/drag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
e0785bd1a1
  1. 82
      src/components/nodes/ModelParameters.tsx

82
src/components/nodes/ModelParameters.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { ProviderType, ModelInputDef } from "@/types"; import { ProviderType, ModelInputDef } from "@/types";
import { ModelParameter } from "@/lib/providers/types"; import { ModelParameter } from "@/lib/providers/types";
import { useProviderApiKeys } from "@/store/workflowStore"; import { useProviderApiKeys } from "@/store/workflowStore";
@ -68,7 +68,7 @@ interface ModelParametersProps {
* Fetches schema from /api/models/{modelId}?provider={provider} * Fetches schema from /api/models/{modelId}?provider={provider}
* and renders appropriate inputs based on parameter types. * and renders appropriate inputs based on parameter types.
*/ */
export function ModelParameters({ function ModelParametersInner({
modelId, modelId,
provider, provider,
parameters, parameters,
@ -176,16 +176,19 @@ export function ModelParameters({
[parameters, onParametersChange] [parameters, onParametersChange]
); );
const sortedSchema = [...schema].sort((a, b) => { const sortedSchema = useMemo(() => {
// Sort order: dropdowns first, then numbers, then strings, then checkboxes last return [...schema].sort((a, b) => {
const typeOrder = (p: ModelParameter) => { // Sort order: dropdowns first, then numbers, then strings, then checkboxes last
if (p.enum && p.enum.length > 0) return 0; // dropdowns first const typeOrder = (p: ModelParameter) => {
if (p.type === "number" || p.type === "integer") return 1; if (p.enum && p.enum.length > 0) return 0; // dropdowns first
if (p.type === "boolean") return 3; // checkboxes last if (p.type === "number" || p.type === "integer") return 1;
return 2; // string and other if (p.type === "boolean") return 3; // checkboxes last
}; return 2; // string and other
return typeOrder(a) - typeOrder(b); };
}); return typeOrder(a) - typeOrder(b);
});
}, [schema]);
const useGrid = sortedSchema.length > 4; const useGrid = sortedSchema.length > 4;
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [colCount, setColCount] = useState(1); const [colCount, setColCount] = useState(1);
@ -193,12 +196,19 @@ export function ModelParameters({
useEffect(() => { useEffect(() => {
const el = gridRef.current; const el = gridRef.current;
if (!el || !useGrid) { setColCount(1); return; } if (!el || !useGrid) { setColCount(1); return; }
let rafId: number;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length; cancelAnimationFrame(rafId);
setColCount(cols); rafId = requestAnimationFrame(() => {
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length;
setColCount(prev => prev === cols ? prev : cols);
});
}); });
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [useGrid]); }, [useGrid]);
// Don't render anything for Gemini or if no model selected // Don't render anything for Gemini or if no model selected
@ -211,9 +221,11 @@ export function ModelParameters({
return null; return null;
} }
const displaySchema = useGrid && colCount > 1 const displaySchema = useMemo(() => {
? reorderColumnFirst(sortedSchema, colCount) return useGrid && colCount > 1
: sortedSchema; ? reorderColumnFirst(sortedSchema, colCount)
: sortedSchema;
}, [sortedSchema, useGrid, colCount]);
return ( return (
<div className="shrink-0"> <div className="shrink-0">
@ -235,8 +247,9 @@ export function ModelParameters({
<ParameterInput <ParameterInput
key={param.name} key={param.name}
param={param} param={param}
name={param.name}
value={parameters[param.name]} value={parameters[param.name]}
onChange={(value) => handleParameterChange(param.name, value)} onChange={handleParameterChange}
/> />
))} ))}
</div> </div>
@ -247,8 +260,9 @@ export function ModelParameters({
interface ParameterInputProps { interface ParameterInputProps {
param: ModelParameter; param: ModelParameter;
name: string;
value: unknown; value: unknown;
onChange: (value: unknown) => void; onChange: (name: string, value: unknown) => void;
} }
/** /**
@ -256,7 +270,11 @@ interface ParameterInputProps {
* Text and number inputs use local state during editing to prevent * Text and number inputs use local state during editing to prevent
* cursor-jump issues caused by React Flow re-renders on store updates. * cursor-jump issues caused by React Flow re-renders on store updates.
*/ */
function ParameterInput({ param, value, onChange }: ParameterInputProps) { function ParameterInputInner({ param, name, value, onChange }: ParameterInputProps) {
// Stable callback that passes name along with value
const handleChange = useCallback((value: unknown) => {
onChange(name, value);
}, [name, onChange]);
const displayName = param.name const displayName = param.name
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
@ -291,15 +309,15 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
if (val === "") { if (val === "") {
onChange(undefined); handleChange(undefined);
} else if (param.type === "integer") { } else if (param.type === "integer") {
onChange(parseInt(val, 10)); handleChange(parseInt(val, 10));
} else if (param.type === "number") { } else if (param.type === "number") {
onChange(parseFloat(val)); handleChange(parseFloat(val));
} else if (param.type === "boolean") { } else if (param.type === "boolean") {
onChange(val === "true"); handleChange(val === "true");
} else { } else {
onChange(val); handleChange(val);
} }
}} }}
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white" className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
@ -328,7 +346,7 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
<input <input
type="checkbox" type="checkbox"
checked={effectiveValue} checked={effectiveValue}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => handleChange(e.target.checked)}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0" className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/> />
<span>{displayName}</span> <span>{displayName}</span>
@ -380,10 +398,10 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
onBlur={() => { onBlur={() => {
isFocusedRef.current = false; isFocusedRef.current = false;
if (localValue === "") { if (localValue === "") {
onChange(undefined); handleChange(undefined);
} else { } else {
const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue); const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue);
onChange(isNaN(num) ? undefined : num); handleChange(isNaN(num) ? undefined : num);
} }
}} }}
placeholder={param.default !== undefined ? `${param.default}` : undefined} placeholder={param.default !== undefined ? `${param.default}` : undefined}
@ -424,7 +442,7 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
}} }}
onBlur={() => { onBlur={() => {
isFocusedRef.current = false; isFocusedRef.current = false;
onChange(localValue || undefined); handleChange(localValue || undefined);
}} }}
placeholder={param.default !== undefined ? `${param.default}` : undefined} placeholder={param.default !== undefined ? `${param.default}` : undefined}
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white placeholder:text-neutral-500" className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white placeholder:text-neutral-500"
@ -432,3 +450,7 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
</div> </div>
); );
} }
// Memoized exports to prevent unnecessary re-renders
export const ModelParameters = React.memo(ModelParametersInner);
const ParameterInput = React.memo(ParameterInputInner);

Loading…
Cancel
Save