Skip to content

Fixes GH-17399 #17747

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 22, 2025
74 changes: 48 additions & 26 deletions packages/connect-react/src/components/ControlSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { useFormFieldContext } from "../hooks/form-field-context";
import { useCustomize } from "../hooks/customization-context";
import type { BaseReactSelectProps } from "../hooks/customization-context";
import { LoadMoreButton } from "./LoadMoreButton";
import {
isOptionWithValue, OptionWithValue, sanitizeOption,
} from "../utils/type-guards";

// XXX T and ConfigurableProp should be related
type ControlSelectProps<T> = {
Expand Down Expand Up @@ -41,10 +44,11 @@ export function ControlSelect<T>({
] = useState(value);

useEffect(() => {
setSelectOptions(options)
const sanitizedOptions = options.map(sanitizeOption);
setSelectOptions(sanitizedOptions);
}, [
options,
])
]);

useEffect(() => {
setRawValue(value)
Expand All @@ -67,11 +71,11 @@ export function ControlSelect<T>({
if (ret != null) {
if (Array.isArray(ret)) {
// if simple, make lv (XXX combine this with other place this happens)
if (typeof ret[0] !== "object") {
if (!isOptionWithValue(ret[0])) {
const lvs = [];
for (const o of ret) {
let obj = {
label: o,
label: String(o),
value: o,
}
for (const item of selectOptions) {
Expand All @@ -84,8 +88,11 @@ export function ControlSelect<T>({
}
ret = lvs;
}
} else if (typeof ret !== "object") {
const lvOptions = selectOptions?.[0] && typeof selectOptions[0] === "object";
} else if (ret && typeof ret === "object" && "__lv" in ret) {
// Extract the actual option from __lv wrapper
ret = ret.__lv;
} else if (!isOptionWithValue(ret)) {
const lvOptions = selectOptions?.[0] && isOptionWithValue(selectOptions[0]);
if (lvOptions) {
for (const item of selectOptions) {
if (item.value === rawValue) {
Expand All @@ -95,12 +102,10 @@ export function ControlSelect<T>({
}
} else {
ret = {
label: rawValue,
label: String(rawValue),
value: rawValue,
}
}
} else if (ret.__lv) {
ret = ret.__lv
}
}
return ret;
Expand All @@ -117,13 +122,14 @@ export function ControlSelect<T>({
<components.MenuList {...props}>
{ children }
<div className="pt-4">
<LoadMoreButton onChange={onLoadMore}/>
<LoadMoreButton onChange={onLoadMore || (() => {})}/>
</div>
</components.MenuList>
)
}

const props = select.getProps("controlSelect", baseSelectProps)

if (showLoadMoreButton) {
props.components = {
// eslint-disable-next-line react/prop-types
Expand All @@ -133,24 +139,26 @@ export function ControlSelect<T>({
}

const handleCreate = (inputValue: string) => {
const createOption = (input: unknown) => {
if (typeof input === "object") return input
const createOption = (input: unknown): OptionWithValue => {
if (isOptionWithValue(input)) return input
const strValue = String(input);
return {
label: input,
value: input,
label: strValue,
value: strValue,
}
}
const newOption = createOption(inputValue)
let newRawValue = newOption
const newSelectOptions = selectOptions
? [
newOption,
...selectOptions,
]
: [
newOption,
]

// NEVER add wrapped objects to selectOptions - only clean {label, value} objects
const cleanSelectOptions = selectOptions.map(sanitizeOption);

const newSelectOptions = [
newOption,
...cleanSelectOptions,
];
setSelectOptions(newSelectOptions);

if (prop.type.endsWith("[]")) {
if (Array.isArray(rawValue)) {
newRawValue = [
Expand All @@ -170,14 +178,14 @@ export function ControlSelect<T>({
const handleChange = (o: unknown) => {
if (o) {
if (Array.isArray(o)) {
if (typeof o[0] === "object" && "value" in o[0]) {
if (typeof o[0] === "object" && o[0] && "value" in o[0]) {
onChange({
__lv: o,
});
} else {
onChange(o);
}
} else if (typeof o === "object" && "value" in o) {
} else if (typeof o === "object" && o && "value" in o) {
onChange({
__lv: o,
});
Expand All @@ -198,19 +206,33 @@ export function ControlSelect<T>({
const MaybeCreatableSelect = isCreatable
? CreatableSelect
: Select;

// Final safety check - ensure NO __lv wrapped objects reach react-select
const cleanedOptions = selectOptions.map(sanitizeOption);

return (
<MaybeCreatableSelect
inputId={id}
instanceId={id}
options={selectOptions}
options={cleanedOptions}
value={selectValue}
isMulti={prop.type.endsWith("[]")}
isClearable={true}
required={!prop.optional}
getOptionLabel={(option) => {
return typeof option === "string"
? option
: String(option?.label || option?.value || "");
}}
getOptionValue={(option) => {
return typeof option === "string"
? option
: String(option?.value || "");
}}
onChange={handleChange}
{...props}
{...selectProps}
{...additionalProps}
onChange={handleChange}
/>
);
}
14 changes: 11 additions & 3 deletions packages/connect-react/src/components/RemoteOptionsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useFormContext } from "../hooks/form-context";
import { useFormFieldContext } from "../hooks/form-field-context";
import { useFrontendClient } from "../hooks/frontend-client-context";
import { ControlSelect } from "./ControlSelect";
import { isString } from "../utils/type-guards";

export type RemoteOptionsContainerProps = {
queryEnabled?: boolean;
Expand Down Expand Up @@ -138,9 +139,16 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP
const newOptions = []
const allValues = new Set(pageable.values)
for (const o of _options || []) {
const value = typeof o === "string"
? o
: o.value
let value: string | number;
if (isString(o)) {
value = o;
} else if (o && typeof o === "object" && "value" in o && o.value != null) {
value = o.value;
} else {
// Skip items that don't match expected format
console.warn("Skipping invalid option:", o);
continue;
}
if (allValues.has(value)) {
continue
}
Expand Down
6 changes: 6 additions & 0 deletions packages/connect-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ export * from "./hooks/use-app";
export * from "./hooks/use-apps";
export * from "./hooks/use-component";
export * from "./hooks/use-components";

// Debug info for development - consumers can choose to log this if needed
export const DEBUG_INFO = {
buildTime: new Date().toISOString(),
source: "local-development",
};
175 changes: 175 additions & 0 deletions packages/connect-react/src/utils/type-guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Represents an option object with a value and optional label.
* Used by react-select and similar components.
*/
export interface OptionWithValue {
/** The actual value of the option (string or number) */
value: string | number;
/** Optional display label for the option */
label?: string;
/** Internal wrapper object (used by form handling logic) */
__lv?: unknown;
}

/**
* Type guard to check if a value is a string.
* @param value - The value to check
* @returns true if the value is a string
*/
export function isString(value: unknown): value is string {
return typeof value === "string";
}

/**
* Type guard to check if a value is a valid OptionWithValue object.
* Validates that the object has a 'value' property that is either a string or number.
* @param value - The value to check
* @returns true if the value is a valid OptionWithValue
*/
export function isOptionWithValue(value: unknown): value is OptionWithValue {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
"value" in value &&
(typeof (value as Record<string, unknown>).value === "string" || typeof (value as Record<string, unknown>).value === "number")
);
}

/**
* Type guard to check if a value is an array of strings.
* @param value - The value to check
* @returns true if the value is a string array
*/
export function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}

/**
* Type guard to check if a value is an array of OptionWithValue objects.
* @param value - The value to check
* @returns true if the value is an array of valid OptionWithValue objects
*/
export function isOptionArray(value: unknown): value is OptionWithValue[] {
return Array.isArray(value) && value.every((item) => isOptionWithValue(item));
}

/**
* Normalizes an unknown value into either a string or OptionWithValue.
* Used for basic option processing where the input format is uncertain.
* @param option - The option to normalize
* @returns A normalized string or OptionWithValue object
*/
export function normalizeOption(option: unknown): OptionWithValue | string {
if (isString(option)) {
return option;
}
if (isOptionWithValue(option)) {
return option;
}
return String(option);
}

/**
* Normalizes an array of unknown values into an array of strings or OptionWithValue objects.
* Handles cases where the input might not be an array by returning an empty array.
* @param options - The options array to normalize
* @returns An array of normalized options
*/
export function normalizeOptions(options: unknown): Array<OptionWithValue | string> {
if (!Array.isArray(options)) {
return [];
}
return options.map(normalizeOption);
}

/**
* Sanitizes an option to ensure it has proper primitive values for label/value.
* This is the main utility for processing complex nested option structures that can
* come from various sources (APIs, form data, etc.) into a format compatible with react-select.
*
* Handles multiple nesting scenarios:
* 1. String options: returned as-is (e.g., "simple-option")
* 2. __lv wrapper objects: extracts inner option from {__lv: {label: "...", value: "..."}}
* 3. Nested label/value objects: handles {label: {label: "Documents"}, value: {value: "123"}}
*
* This function was created to fix React error #31 where nested objects were being
* passed to React components that expected primitive values.
*
* @param option - The option to sanitize (can be string, object, or complex nested structure)
* @returns A clean option with primitive label/value or a string
*
* @example
* // Simple string
* sanitizeOption("hello") // returns "hello"
*
* @example
* // Nested object structure
* sanitizeOption({
* label: {label: "Documents", value: "123"},
* value: {label: "Documents", value: "123"}
* }) // returns {label: "Documents", value: "123"}
*
* @example
* // __lv wrapper
* sanitizeOption({
* __lv: {label: "Test", value: "test-id"}
* }) // returns {label: "Test", value: "test-id"}
*/
export function sanitizeOption(option: unknown): { label: string; value: unknown } | string {
if (typeof option === "string") return option;

if (!option || typeof option !== "object") {
return {
label: "",
value: "",
};
}

// If option has __lv wrapper, extract the inner option
if ("__lv" in option) {
const innerOption = (option as Record<string, unknown>).__lv;

let actualLabel = "";
let actualValue = innerOption?.value;

// Handle nested label in __lv
if (innerOption?.label && typeof innerOption.label === "object" && "label" in innerOption.label) {
actualLabel = String(innerOption.label.label || "");
} else {
actualLabel = String(innerOption?.label || innerOption?.value || "");
}

// Handle nested value in __lv
if (innerOption?.value && typeof innerOption.value === "object" && "value" in innerOption.value) {
actualValue = innerOption.value.value;
}

return {
label: actualLabel,
value: actualValue,
};
}

// Handle nested label and value objects
const optionObj = option as Record<string, unknown>;
let actualLabel = "";
let actualValue = optionObj.value;

// Extract nested label
if (optionObj.label && typeof optionObj.label === "object" && "label" in optionObj.label) {
actualLabel = String(optionObj.label.label || "");
} else {
actualLabel = String(optionObj.label || optionObj.value || "");
}

// Extract nested value
if (optionObj.value && typeof optionObj.value === "object" && "value" in optionObj.value) {
actualValue = optionObj.value.value;
}

return {
label: actualLabel,
value: actualValue,
};
}
Loading
Loading