Skip to content

Commit 953db56

Browse files
dannyrooseveltrnijharaclaude
authored
Fix pre-configured props handling issues v2 (#18872)
* Fix pre-configured props handling in forms ## Issues Fixed ### Issue 1: Remote options not loading with pre-configured values - **Problem**: When mounting ComponentFormContainer with pre-configured props, remote options dropdowns showed "No options" even though the API returned data - **Root Cause**: queryDisabledIdx initialization used _configuredProps (empty) instead of actual configuredProps, incorrectly blocking queries. RemoteOptionsContainer also didn't sync cached query data with component state on remount - **Files**: form-context.tsx, RemoteOptionsContainer.tsx ### Issue 2: Optional props not auto-enabling when pre-configured - **Problem**: Optional fields with saved values were hidden when switching back to a previously configured component - **Root Cause**: enabledOptionalProps reset on component change, never re-enabling optional fields that had values - **File**: form-context.tsx ### Issue 3: Optional prop values lost during state sync - **Problem**: Optional field values were discarded during the state synchronization effect if the field wasn't enabled - **Root Cause**: Sync effect skipped disabled optional props entirely - **File**: form-context.tsx ## Fixes Applied ### form-context.tsx 1. Fixed queryDisabledIdx initialization to use configuredProps instead of _configuredProps - Changed dependency from _configuredProps to component.key - Ensures blocking index is calculated from actual current values including parent-passed props 2. Added useEffect to auto-enable optional props with values - Runs when component key or configurableProps/configuredProps change - Automatically enables any optional props that have values in configuredProps - Ensures optional fields with saved values are shown on mount 3. Modified sync effect to preserve optional prop values - Optional props that aren't enabled still have their values preserved - Prevents data loss during state synchronization ### RemoteOptionsContainer.tsx 1. Destructured data from useQuery return - Added data to destructured values to track query results 2. Modified queryFn to return pageable object - Changed from returning just raw data array to returning full newPageable state object - Enables proper state syncing 3. Added useEffect to sync pageable state with query data - Handles both fresh API calls and React Query cached returns - When cached data is returned, queryFn doesn't run but useEffect syncs the state - Ensures options populate correctly on component remount ## Expected Behavior After Fixes ✓ Remote option fields load correctly when mounting with pre-configured values ✓ Dropdown shows fetched options even when using cached data ✓ Optional fields with saved values are automatically enabled and visible ✓ No data loss when switching between components ✓ Smooth component switching with all values and options preserved 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]> * fix: clear dropdown options when dependent field changes When a dependent field changes (e.g., Channel Type: "Channels" → "User/Direct Message"), the Channel dropdown should replace its options instead of accumulating them. The fix uses page-based logic to determine whether to replace or append options: - page === 0 (fresh query): Replace options with new data - page > 0 (pagination): Append options to existing data When dependent fields change, the useEffect resets page to 0, which triggers the queryFn to replace options instead of appending. This prevents accumulation of options from different queries. Additionally, the allValues Set is reset on fresh queries to ensure deduplication starts fresh, not carrying over values from the previous query. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: prevent duplicate API calls from race condition in form state When a field value changes, two /configure API calls were being made: 1. First call with empty configured_props: {} 2. Second call with correct configured_props: {field: value} Root cause: In setConfiguredProp, updateConfiguredPropsQueryDisabledIdx was called synchronously, updating queryDisabledIdx state before configuredProps state update completed. This caused children to re-render twice with mismatched state. Fix: Move queryDisabledIdx update to a reactive useEffect that watches configuredProps changes. This ensures both state updates complete before children re-render, preventing the duplicate API call with stale data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: handle integer dropdown values and error states properly Two related fixes to prevent field value loss and crashes: 1. Preserve label-value format for integer props When integer properties with remoteOptions (like worksheetId) are selected from dropdowns, the values are stored in label-value format: {__lv: {label, value}}. The sync effect was incorrectly deleting these values because they weren't pure numbers. Now preserves __lv format for remote option dropdowns. 2. Return proper pageable structure on error in RemoteOptionsContainer When /configure returns an error, queryFn was returning [] instead of the expected pageable object {page, data, prevContext, values}. This caused pageable.data.map() to crash. Now returns proper structure on error to prevent crashes and display error message correctly. Fixes: - Worksheet ID field no longer resets after dynamic props reload - No more crash when clearing app field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * style: fix eslint formatting errors * fix: add type annotations for PropOptionValue Sets * fix: handle multi-select integer fields with __lv format Previously, multi-select integer fields (e.g., Worksheet ID(s)) displayed "[object Object]" instead of proper labels when populated with pre-configured values. This occurred because: 1. form-context.tsx only checked for single __lv objects, not arrays 2. ControlSelect.tsx tried to sanitize entire arrays instead of individual items Changes: - form-context.tsx: Check for both single __lv objects and arrays of __lv objects to preserve multi-select values during sync - ControlSelect.tsx: Extract array contents from __lv wrapper and map each item through sanitizeOption for proper rendering This completes the fix for pre-configured props handling with remote options. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Code cleanup and PR feedback * More PR feedback and code cleanup * Version and changelog * PR feedback * Update form-context.tsx * some code cleanup * more cleanup --------- Co-authored-by: Roopak Nijhara <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 2ac6cfa commit 953db56

File tree

5 files changed

+140
-16
lines changed

5 files changed

+140
-16
lines changed

packages/connect-react/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
# Changelog
44

5+
## [2.1.1] - 2025-10-27
6+
7+
### Fixed
8+
9+
- Fixed optional props being removed when loading saved configurations
10+
- Optional props with values now automatically display as enabled
11+
- Improved handling of label-value format for remote options in multi-select fields
12+
513
## [2.1.0] - 2025-10-10
614

715
### Added

packages/connect-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pipedream/connect-react",
3-
"version": "2.1.0",
3+
"version": "2.1.1",
44
"description": "Pipedream Connect library for React",
55
"files": [
66
"dist"

packages/connect-react/src/components/ControlSelect.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@ import CreatableSelect from "react-select/creatable";
1616
import type { BaseReactSelectProps } from "../hooks/customization-context";
1717
import { useCustomize } from "../hooks/customization-context";
1818
import { useFormFieldContext } from "../hooks/form-field-context";
19-
import { LabelValueOption } from "../types";
19+
import type {
20+
LabelValueOption,
21+
RawPropOption,
22+
} from "../types";
2023
import {
2124
isOptionWithLabel,
2225
sanitizeOption,
2326
} from "../utils/type-guards";
27+
import {
28+
isArrayOfLabelValueWrapped,
29+
isLabelValueWrapped,
30+
} from "../utils/label-value";
2431
import { LoadMoreButton } from "./LoadMoreButton";
2532

2633
// XXX T and ConfigurableProp should be related
@@ -85,15 +92,25 @@ export function ControlSelect<T extends PropOptionValue>({
8592
return null;
8693
}
8794

95+
// Handle __lv-wrapped values (single object or array) returned from remote options
96+
if (isLabelValueWrapped<T>(rawValue)) {
97+
const lvContent = rawValue.__lv;
98+
if (Array.isArray(lvContent)) {
99+
return lvContent.map((item) => sanitizeOption<T>(item as RawPropOption<T>));
100+
}
101+
return sanitizeOption<T>(lvContent as RawPropOption<T>);
102+
}
103+
104+
if (isArrayOfLabelValueWrapped<T>(rawValue)) {
105+
return rawValue.map((item) => sanitizeOption<T>(item as RawPropOption<T>));
106+
}
107+
88108
if (Array.isArray(rawValue)) {
89109
// if simple, make lv (XXX combine this with other place this happens)
90110
if (!isOptionWithLabel(rawValue[0])) {
91111
return rawValue.map((o) =>
92112
selectOptions.find((item) => item.value === o) || sanitizeOption(o as T));
93113
}
94-
} else if (rawValue && typeof rawValue === "object" && "__lv" in (rawValue as Record<string, unknown>)) {
95-
// Extract the actual option from __lv wrapper and sanitize to LV
96-
return sanitizeOption(((rawValue as Record<string, unknown>).__lv) as T);
97114
} else if (!isOptionWithLabel(rawValue)) {
98115
const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]);
99116
if (lvOptions) {

packages/connect-react/src/hooks/form-context.tsx

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from "../types";
3737
import { resolveUserId } from "../utils/resolve-user-id";
3838
import { isConfigurablePropOfType } from "../utils/type-guards";
39+
import { hasLabelValueFormat } from "../utils/label-value";
3940

4041
export type AnyFormFieldContext = Omit<FormFieldContext<ConfigurableProp>, "onChange"> & {
4142
onChange: (value: unknown) => void;
@@ -169,6 +170,7 @@ export const FormContextProvider = <T extends ConfigurableProps>({
169170
}, [
170171
component.key,
171172
]);
173+
172174
// XXX pass this down? (in case we make it hash or set backed, but then also provide {add,remove} instead of set)
173175
const optionalPropIsEnabled = (prop: ConfigurableProp) => enabledOptionalProps[prop.name];
174176

@@ -354,13 +356,35 @@ export const FormContextProvider = <T extends ConfigurableProps>({
354356
setErrors(_errors);
355357
};
356358

359+
const preserveIntegerValue = (prop: ConfigurableProp, value: unknown) => {
360+
if (prop.type !== "integer" || typeof value === "number") {
361+
return value;
362+
}
363+
return hasLabelValueFormat(value)
364+
? value
365+
: undefined;
366+
};
367+
357368
useEffect(() => {
358-
// Initialize queryDisabledIdx on load so that we don't force users
359-
// to reconfigure a prop they've already configured whenever the page
360-
// or component is reloaded
361-
updateConfiguredPropsQueryDisabledIdx(_configuredProps)
369+
// Initialize queryDisabledIdx using actual configuredProps (includes parent-passed values in controlled mode)
370+
// instead of _configuredProps which starts empty. This ensures that when mounting with pre-configured
371+
// values, remote options queries are not incorrectly blocked.
372+
updateConfiguredPropsQueryDisabledIdx(configuredProps)
362373
}, [
363-
_configuredProps,
374+
component.key,
375+
configurableProps,
376+
enabledOptionalProps,
377+
]);
378+
379+
// Update queryDisabledIdx reactively when configuredProps changes.
380+
// This prevents race conditions where queryDisabledIdx updates synchronously before
381+
// configuredProps completes its state update, causing duplicate API calls with stale data.
382+
useEffect(() => {
383+
updateConfiguredPropsQueryDisabledIdx(configuredProps);
384+
}, [
385+
configuredProps,
386+
configurableProps,
387+
enabledOptionalProps,
364388
]);
365389

366390
useEffect(() => {
@@ -386,8 +410,13 @@ export const FormContextProvider = <T extends ConfigurableProps>({
386410
if (skippablePropTypes.includes(prop.type)) {
387411
continue;
388412
}
389-
// if prop.optional and not shown, we skip and do on un-collapse
413+
// if prop.optional and not shown, we still preserve the value if it exists
414+
// This prevents losing saved values for optional props that haven't been enabled yet
390415
if (prop.optional && !optionalPropIsEnabled(prop)) {
416+
const value = configuredProps[prop.name as keyof ConfiguredProps<T>];
417+
if (value !== undefined) {
418+
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
419+
}
391420
continue;
392421
}
393422
const value = configuredProps[prop.name as keyof ConfiguredProps<T>];
@@ -397,10 +426,14 @@ export const FormContextProvider = <T extends ConfigurableProps>({
397426
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = prop.default as any; // eslint-disable-line @typescript-eslint/no-explicit-any
398427
}
399428
} else {
400-
if (prop.type === "integer" && typeof value !== "number") {
429+
// Preserve label-value format from remote options dropdowns for integer props.
430+
// Remote options store values as {__lv: {label: "...", value: ...}} (or arrays of __lv objects).
431+
// For integer props we drop anything that isn't number or label-value formatted to avoid corrupt data.
432+
const preservedValue = preserveIntegerValue(prop, value);
433+
if (preservedValue === undefined) {
401434
delete newConfiguredProps[prop.name as keyof ConfiguredProps<T>];
402435
} else {
403-
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = value;
436+
newConfiguredProps[prop.name as keyof ConfiguredProps<T>] = preservedValue as any; // eslint-disable-line @typescript-eslint/no-explicit-any
404437
}
405438
}
406439
}
@@ -409,6 +442,8 @@ export const FormContextProvider = <T extends ConfigurableProps>({
409442
}
410443
}, [
411444
configurableProps,
445+
enabledOptionalProps,
446+
configuredProps,
412447
]);
413448

414449
// clear all props on user change
@@ -440,9 +475,6 @@ export const FormContextProvider = <T extends ConfigurableProps>({
440475
if (prop.reloadProps) {
441476
setReloadPropIdx(idx);
442477
}
443-
if (prop.type === "app" || prop.remoteOptions) {
444-
updateConfiguredPropsQueryDisabledIdx(newConfiguredProps);
445-
}
446478
const errs = propErrors(prop, value);
447479
const newErrors = {
448480
...errors,
@@ -478,6 +510,23 @@ export const FormContextProvider = <T extends ConfigurableProps>({
478510
setEnabledOptionalProps(newEnabledOptionalProps);
479511
};
480512

513+
// Auto-enable optional props with saved values so dependent dynamic props reload correctly
514+
useEffect(() => {
515+
for (const prop of configurableProps) {
516+
if (!prop.optional) continue;
517+
if (enabledOptionalProps[prop.name]) continue;
518+
const value = configuredProps[prop.name as keyof ConfiguredProps<T>];
519+
if (value === undefined) continue;
520+
optionalPropSetEnabled(prop, true);
521+
}
522+
}, [
523+
component.key,
524+
configurableProps,
525+
configuredProps,
526+
enabledOptionalProps,
527+
optionalPropSetEnabled,
528+
]);
529+
481530
const checkPropsNeedConfiguring = () => {
482531
const _propsNeedConfiguring = []
483532
for (const prop of configurableProps) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { PropOptionValue } from "@pipedream/sdk";
2+
import type { RawPropOption } from "../types";
3+
4+
/**
5+
* Utilities for detecting and handling label-value (__lv) format
6+
* used by Pipedream components to preserve display labels for option values.
7+
*/
8+
9+
/**
10+
* Shape returned by remote options when values include their original label.
11+
* The wrapped payload may itself be a single option or an array of options.
12+
*/
13+
export type LabelValueWrapped<T extends PropOptionValue = PropOptionValue> = Extract<RawPropOption<T>, { __lv: unknown }>;
14+
15+
/**
16+
* Runtime type guard for the label-value wrapper.
17+
* @param value - The value to check
18+
* @returns true if value is an object with a non-null __lv payload
19+
*/
20+
export function isLabelValueWrapped<T extends PropOptionValue = PropOptionValue>(
21+
value: unknown,
22+
): value is LabelValueWrapped<T> {
23+
if (!value || typeof value !== "object") return false;
24+
if (!("__lv" in value)) return false;
25+
26+
const lvContent = (value as LabelValueWrapped<T>).__lv;
27+
return lvContent != null;
28+
}
29+
30+
/**
31+
* Checks if every entry in an array is a label-value wrapper.
32+
* @param value - The value to check
33+
* @returns true if all entries are wrapped and contain non-null payloads
34+
*/
35+
export function isArrayOfLabelValueWrapped<T extends PropOptionValue = PropOptionValue>(
36+
value: unknown,
37+
): value is Array<LabelValueWrapped<T>> {
38+
if (!Array.isArray(value) || value.length === 0) return false;
39+
40+
return value.every((item) => isLabelValueWrapped<T>(item));
41+
}
42+
43+
/**
44+
* Checks if a value has the label-value format (either single or array)
45+
* @param value - The value to check
46+
* @returns true if value is in __lv format (single or array)
47+
*/
48+
export function hasLabelValueFormat(value: unknown): boolean {
49+
return isLabelValueWrapped(value) || isArrayOfLabelValueWrapped(value);
50+
}

0 commit comments

Comments
 (0)