diff --git a/src/pages/cluster-management/components/cluster-form.tsx b/src/pages/cluster-management/components/cluster-form.tsx index 616bed8b..66dda669 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -97,7 +97,14 @@ const ClusterForm: React.FC = forwardRef( // unmounted when model is selected, but strip it here too so the payload // never keeps a stale gpuInstanceOptions shape from a prior "gpu" choice. if (clusterType === 'model') { - delete next.gpuInstanceOptions; + next.gpuInstanceOptions = null; + } else if (clusterType === 'gpu' && !next.gpuInstanceOptions) { + // gpuInstanceOptions has no always-mounted Form.Item (its only child, + // the optional static address, may be unmounted or empty), so with + // preserve={false} onFinish's `values` can omit it. Read it straight + // from the store so a "gpu" cluster always carries the field. + next.gpuInstanceOptions = + form.getFieldValue(['k8s_options', 'gpuInstanceOptions']) ?? {}; } const creds = opts.imageCredentials; diff --git a/src/pages/cluster-management/components/k8s-pod-spec.tsx b/src/pages/cluster-management/components/k8s-pod-spec.tsx index b306ee28..50d9aa5b 100644 --- a/src/pages/cluster-management/components/k8s-pod-spec.tsx +++ b/src/pages/cluster-management/components/k8s-pod-spec.tsx @@ -70,14 +70,6 @@ export const OperatorImageForm: React.FC = () => { ); }; -// `gpuInstanceOptions` on `k8s_options` is the submitted representation of a -// "gpu" cluster. Its presence is driven by the shared `clusterType` state (see -// FormContext) — the selector writes it, the static-address field mounts under -// it, and submit strips it for "model". Kept out of any Form.useWatch because -// this path has no always-mounted Form.Item and watching it re-rendered -// unreliably. -const GPU_INSTANCE_OPTIONS_PATH = ['k8s_options', 'gpuInstanceOptions']; - // Visual parity with @gpustack/core-ui's SwitchCard so the selector blends // in with surrounding form fields: same border, radius, padding, and // typography. The only differences are the two-column grid layout and an @@ -172,32 +164,22 @@ const RadioDot = styled.span<{ $active: boolean }>` // Card-based selector for cluster type. The two options are mutually exclusive // and the choice maps directly to the presence/absence of `gpuInstanceOptions` -// on the form — "model" clears it, "gpu" seeds it to {} (preserving any -// already-entered static address). No standalone form field is registered; the -// selected card is tracked in local state (seeded from the form's initial -// value) and written back to the form on each click. +// on the submitted payload. No standalone form field is registered; the click +// only updates the shared `clusterType` state (see FormContext) — the payload's +// `gpuInstanceOptions` shape is derived from it at submit (see cluster-form's +// normalizeOutgoing), and the static-address field mounts/unmounts off it. export const ClusterTypeSelector: React.FC = () => { const intl = useIntl(); - const form = Form.useFormInstance(); const { presetClusterType } = useStepsContext(); const labelId = useId(); // Cluster type is shared, explicit state (see FormContext): the click is the - // source of truth. We update that state and mirror the choice onto the form - // for submission. This replaced a Form.useWatch on an unregistered path that + // source of truth. This replaced a Form.useWatch on an unregistered path that // did not re-render reliably when cleared to undefined. const { clusterType, setClusterType } = useFormContext(); const value: 'model' | 'gpu' = clusterType ?? 'model'; const handleSelect = useMemoizedFn((next: 'model' | 'gpu') => { - if (!form || next === value) return; - if (next === 'gpu') { - form.setFieldValue( - GPU_INSTANCE_OPTIONS_PATH, - form.getFieldValue(GPU_INSTANCE_OPTIONS_PATH) ?? {} - ); - } else { - form.setFieldValue(GPU_INSTANCE_OPTIONS_PATH, undefined); - } + if (next === value) return; setClusterType?.(next); }); diff --git a/src/pages/cluster-management/step-forms/basic-form.tsx b/src/pages/cluster-management/step-forms/basic-form.tsx index 2b538528..22ee34d2 100644 --- a/src/pages/cluster-management/step-forms/basic-form.tsx +++ b/src/pages/cluster-management/step-forms/basic-form.tsx @@ -18,10 +18,17 @@ const BasicForm = forwardRef((props: BasicFormProps, ref) => { console.log(values); }; + // Forward through to the live ClusterForm ref on each call instead of + // snapshotting its methods. ClusterForm rebuilds these closures whenever its + // internal state (e.g. clusterType) changes, but BasicForm does not re-render + // with it — a frozen snapshot would keep calling stale closures (reading the + // initial clusterType) and be null on the first render before the ref attaches. useImperativeHandle(ref, () => ({ - validateFields: formRef.current?.validateFields, - getFieldsValue: formRef.current?.getFieldsValue, - submit: formRef.current?.submit + validateFields: (...args: any[]) => + formRef.current?.validateFields(...args), + getFieldsValue: (...args: any[]) => + formRef.current?.getFieldsValue(...args), + submit: (...args: any[]) => formRef.current?.submit(...args) })); return (