From 4d54aca86bc9e5eac8f3edf993f73791a9e8dc17 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 14:11:23 +0530 Subject: [PATCH 01/12] Fix pre-configured props handling in forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../src/components/RemoteOptionsContainer.tsx | 26 ++++++++---- .../connect-react/src/hooks/form-context.tsx | 40 ++++++++++++++++--- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index f3016cdebafe9..dd0c503329ae5 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -2,7 +2,7 @@ import type { ConfigurePropOpts, PropOptionValue, } from "@pipedream/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useFormContext } from "../hooks/form-context"; import { useFormFieldContext } from "../hooks/form-field-context"; import { useFrontendClient } from "../hooks/frontend-client-context"; @@ -106,6 +106,7 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP // TODO handle error! const { + data, isFetching, refetch, } = useQuery({ queryKey: [ @@ -167,26 +168,37 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP allValues.add(value) newOptions.push(o) } - let data = pageable.data + let responseData = pageable.data if (newOptions.length) { - data = [ + responseData = [ ...pageable.data, ...newOptions, ] as RawPropOption[] - setPageable({ + const newPageable = { page: page + 1, prevContext: res.context, - data, + data: responseData, values: allValues, - }) + } + setPageable(newPageable) + return newPageable; } else { setCanLoadMore(false) + return pageable; } - return data; }, enabled: !!queryEnabled, }); + // Sync pageable state with query data to handle both fresh fetches and cached returns + // When React Query returns cached data, the queryFn doesn't run, so we need to sync + // the state here to ensure options populate correctly on remount + useEffect(() => { + if (data) { + setPageable(data); + } + }, [data]); + const showLoadMoreButton = () => { return !isFetching && !error && canLoadMore } diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 586f6ae3981bf..03071ecc2774c 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -169,6 +169,7 @@ export const FormContextProvider = ({ }, [ component.key, ]); + // XXX pass this down? (in case we make it hash or set backed, but then also provide {add,remove} instead of set) const optionalPropIsEnabled = (prop: ConfigurableProp) => enabledOptionalProps[prop.name]; @@ -275,6 +276,28 @@ export const FormContextProvider = ({ reloadPropIdx, ]); + // Auto-enable optional props that have values in configuredProps + // This ensures optional fields with saved values are shown when mounting with pre-configured props + useEffect(() => { + const propsToEnable: Record = {}; + + for (const prop of configurableProps) { + if (prop.optional) { + const value = configuredProps[prop.name as keyof ConfiguredProps]; + if (value !== undefined) { + propsToEnable[prop.name] = true; + } + } + } + + if (Object.keys(propsToEnable).length > 0) { + setEnabledOptionalProps(prev => ({ + ...prev, + ...propsToEnable, + })); + } + }, [component.key, configurableProps, configuredProps]); + // these validations are necessary because they might override PropInput for number case for instance // so can't rely on that base control form validation const propErrors = (prop: ConfigurableProp, value: unknown): string[] => { @@ -355,12 +378,12 @@ export const FormContextProvider = ({ }; useEffect(() => { - // Initialize queryDisabledIdx on load so that we don't force users - // to reconfigure a prop they've already configured whenever the page - // or component is reloaded - updateConfiguredPropsQueryDisabledIdx(_configuredProps) + // Initialize queryDisabledIdx using actual configuredProps (includes parent-passed values in controlled mode) + // instead of _configuredProps which starts empty. This ensures that when mounting with pre-configured + // values, remote options queries are not incorrectly blocked. + updateConfiguredPropsQueryDisabledIdx(configuredProps) }, [ - _configuredProps, + component.key, ]); useEffect(() => { @@ -386,8 +409,13 @@ export const FormContextProvider = ({ if (skippablePropTypes.includes(prop.type)) { continue; } - // if prop.optional and not shown, we skip and do on un-collapse + // if prop.optional and not shown, we still preserve the value if it exists + // This prevents losing saved values for optional props that haven't been enabled yet if (prop.optional && !optionalPropIsEnabled(prop)) { + const value = configuredProps[prop.name as keyof ConfiguredProps]; + if (value !== undefined) { + newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + } continue; } const value = configuredProps[prop.name as keyof ConfiguredProps]; From cde427bf0d2925c4bdbec87fbc474b5b3a9881bf Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 15:11:05 +0530 Subject: [PATCH 02/12] fix: clear dropdown options when dependent field changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/RemoteOptionsContainer.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index dd0c503329ae5..a71c2b6a5171e 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -95,6 +95,14 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP setError, ] = useState<{ name: string; message: string; }>(); + // Reset pagination and error when dependent fields change. + // This ensures the next query starts fresh from page 0, triggering a data replace instead of append + useEffect(() => { + setPage(0); + setCanLoadMore(true); + setError(undefined); + }, [externalUserId, component.key, prop.name, JSON.stringify(configuredPropsUpTo)]); + const onLoadMore = () => { setPage(pageable.page) setContext(pageable.prevContext) @@ -149,8 +157,10 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP _options = options; } + // For fresh queries (page 0), start with empty set to avoid accumulating old options + // For pagination (page > 0), use existing set to dedupe across pages + const allValues = page === 0 ? new Set() : new Set(pageable.values) const newOptions = [] - const allValues = new Set(pageable.values) for (const o of _options || []) { let value: PropOptionValue; if (isString(o)) { @@ -170,10 +180,10 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP } let responseData = pageable.data if (newOptions.length) { - responseData = [ - ...pageable.data, - ...newOptions, - ] as RawPropOption[] + // Replace data on fresh queries (page 0), append on pagination (page > 0) + responseData = page === 0 + ? newOptions as RawPropOption[] + : [...pageable.data, ...newOptions] as RawPropOption[] const newPageable = { page: page + 1, prevContext: res.context, From 899e22453a2c68b72217af1b2a3f571ecccb7ff6 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 15:44:07 +0530 Subject: [PATCH 03/12] fix: prevent duplicate API calls from race condition in form state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/connect-react/src/hooks/form-context.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 03071ecc2774c..3ebc581261a3b 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -386,6 +386,15 @@ export const FormContextProvider = ({ component.key, ]); + // Update queryDisabledIdx reactively when configuredProps changes. + // This prevents race conditions where queryDisabledIdx updates synchronously before + // configuredProps completes its state update, causing duplicate API calls with stale data. + useEffect(() => { + updateConfiguredPropsQueryDisabledIdx(configuredProps); + }, [ + configuredProps, + ]); + useEffect(() => { updateConfigurationErrors(configuredProps) }, [ @@ -468,9 +477,6 @@ export const FormContextProvider = ({ if (prop.reloadProps) { setReloadPropIdx(idx); } - if (prop.type === "app" || prop.remoteOptions) { - updateConfiguredPropsQueryDisabledIdx(newConfiguredProps); - } const errs = propErrors(prop, value); const newErrors = { ...errors, From eb4315a8dc62600e420b5a2e580ceee1e1c5b7fd Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 16:15:16 +0530 Subject: [PATCH 04/12] fix: handle integer dropdown values and error states properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/RemoteOptionsContainer.tsx | 8 +++++++- packages/connect-react/src/hooks/form-context.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index a71c2b6a5171e..d5cb00d83338f 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -140,7 +140,13 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP message: errors[0], }); } - return []; + // Return proper pageable structure on error to prevent crashes + return { + page: 0, + prevContext: {}, + data: [], + values: new Set(), + }; } let _options: RawPropOption[] = [] if (options?.length) { diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 3ebc581261a3b..9f19bf6034366 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -435,7 +435,13 @@ export const FormContextProvider = ({ } } else { if (prop.type === "integer" && typeof value !== "number") { - delete newConfiguredProps[prop.name as keyof ConfiguredProps]; + // Preserve label-value format from remote options dropdowns + // Remote options store values as {__lv: {label: "...", value: ...}} + if (!(value && typeof value === "object" && "__lv" in value)) { + delete newConfiguredProps[prop.name as keyof ConfiguredProps]; + } else { + newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + } } else { newConfiguredProps[prop.name as keyof ConfiguredProps] = value; } From f853fb64864e7006d47882b071d80df4b1e2623a Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 17:27:24 +0530 Subject: [PATCH 05/12] style: fix eslint formatting errors --- .../src/components/RemoteOptionsContainer.tsx | 24 +++++++++++++++---- .../connect-react/src/hooks/form-context.tsx | 8 +++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index d5cb00d83338f..8f93febbe73bb 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -2,7 +2,9 @@ import type { ConfigurePropOpts, PropOptionValue, } from "@pipedream/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { + useEffect, useState, +} from "react"; import { useFormContext } from "../hooks/form-context"; import { useFormFieldContext } from "../hooks/form-field-context"; import { useFrontendClient } from "../hooks/frontend-client-context"; @@ -101,7 +103,12 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP setPage(0); setCanLoadMore(true); setError(undefined); - }, [externalUserId, component.key, prop.name, JSON.stringify(configuredPropsUpTo)]); + }, [ + externalUserId, + component.key, + prop.name, + JSON.stringify(configuredPropsUpTo), + ]); const onLoadMore = () => { setPage(pageable.page) @@ -165,7 +172,9 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP // For fresh queries (page 0), start with empty set to avoid accumulating old options // For pagination (page > 0), use existing set to dedupe across pages - const allValues = page === 0 ? new Set() : new Set(pageable.values) + const allValues = page === 0 + ? new Set() + : new Set(pageable.values) const newOptions = [] for (const o of _options || []) { let value: PropOptionValue; @@ -189,7 +198,10 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP // Replace data on fresh queries (page 0), append on pagination (page > 0) responseData = page === 0 ? newOptions as RawPropOption[] - : [...pageable.data, ...newOptions] as RawPropOption[] + : [ + ...pageable.data, + ...newOptions, + ] as RawPropOption[] const newPageable = { page: page + 1, prevContext: res.context, @@ -213,7 +225,9 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP if (data) { setPageable(data); } - }, [data]); + }, [ + data, + ]); const showLoadMoreButton = () => { return !isFetching && !error && canLoadMore diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 9f19bf6034366..10a165357ac9d 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -291,12 +291,16 @@ export const FormContextProvider = ({ } if (Object.keys(propsToEnable).length > 0) { - setEnabledOptionalProps(prev => ({ + setEnabledOptionalProps((prev) => ({ ...prev, ...propsToEnable, })); } - }, [component.key, configurableProps, configuredProps]); + }, [ + component.key, + configurableProps, + configuredProps, + ]); // these validations are necessary because they might override PropInput for number case for instance // so can't rely on that base control form validation From 97aa7dd0bd4cb2a46ae60223649656521f9f1c27 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Thu, 16 Oct 2025 17:29:19 +0530 Subject: [PATCH 06/12] fix: add type annotations for PropOptionValue Sets --- .../connect-react/src/components/RemoteOptionsContainer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/connect-react/src/components/RemoteOptionsContainer.tsx b/packages/connect-react/src/components/RemoteOptionsContainer.tsx index 8f93febbe73bb..e471ff4a609e4 100644 --- a/packages/connect-react/src/components/RemoteOptionsContainer.tsx +++ b/packages/connect-react/src/components/RemoteOptionsContainer.tsx @@ -152,7 +152,7 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP page: 0, prevContext: {}, data: [], - values: new Set(), + values: new Set(), }; } let _options: RawPropOption[] = [] @@ -173,7 +173,7 @@ export function RemoteOptionsContainer({ queryEnabled }: RemoteOptionsContainerP // For fresh queries (page 0), start with empty set to avoid accumulating old options // For pagination (page > 0), use existing set to dedupe across pages const allValues = page === 0 - ? new Set() + ? new Set() : new Set(pageable.values) const newOptions = [] for (const o of _options || []) { From 0a0d9f8f3995749c44606134d81af8999a3e2e23 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Fri, 17 Oct 2025 15:29:20 +0530 Subject: [PATCH 07/12] fix: handle multi-select integer fields with __lv format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/connect-react/src/components/ControlSelect.tsx | 7 ++++++- packages/connect-react/src/hooks/form-context.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 3d7c0a3499853..dc3d41bddca1e 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -92,7 +92,12 @@ export function ControlSelect({ } } else if (rawValue && typeof rawValue === "object" && "__lv" in (rawValue as Record)) { // Extract the actual option from __lv wrapper and sanitize to LV - return sanitizeOption(((rawValue as Record).__lv) as T); + // Handle both single objects and arrays wrapped in __lv + const lvContent = (rawValue as Record).__lv; + if (Array.isArray(lvContent)) { + return lvContent.map((item) => sanitizeOption(item as T)); + } + return sanitizeOption(lvContent as T); } else if (!isOptionWithLabel(rawValue)) { const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]); if (lvOptions) { diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 10a165357ac9d..e7d422d61c113 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -441,7 +441,12 @@ export const FormContextProvider = ({ if (prop.type === "integer" && typeof value !== "number") { // Preserve label-value format from remote options dropdowns // Remote options store values as {__lv: {label: "...", value: ...}} - if (!(value && typeof value === "object" && "__lv" in value)) { + // For multi-select fields, this will be an array of __lv objects + const isLabelValue = value && typeof value === "object" && "__lv" in value; + const isArrayOfLabelValues = Array.isArray(value) && value.length > 0 && + value.every((item) => item && typeof item === "object" && "__lv" in item); + + if (!(isLabelValue || isArrayOfLabelValues)) { delete newConfiguredProps[prop.name as keyof ConfiguredProps]; } else { newConfiguredProps[prop.name as keyof ConfiguredProps] = value; From d628cbd0a976ff8c42d9cd535411ce477762c54f Mon Sep 17 00:00:00 2001 From: Danny Roosevelt Date: Mon, 27 Oct 2025 10:26:03 -0700 Subject: [PATCH 08/12] Code cleanup and PR feedback --- .../connect-react/src/components/ControlSelect.tsx | 4 ++++ packages/connect-react/src/hooks/form-context.tsx | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 80069b0882e0b..433bfe4fb42ac 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -95,6 +95,10 @@ export function ControlSelect({ // Extract the actual option from __lv wrapper and sanitize to LV // Handle both single objects and arrays wrapped in __lv const lvContent = (rawValue as Record).__lv; + if (!lvContent) { + console.warn("Invalid __lv content:", rawValue); + return null; + } if (Array.isArray(lvContent)) { return lvContent.map((item) => sanitizeOption(item as T)); } diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index e7d422d61c113..704f5039dab0a 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -282,7 +282,7 @@ export const FormContextProvider = ({ const propsToEnable: Record = {}; for (const prop of configurableProps) { - if (prop.optional) { + if (prop.optional && !enabledOptionalProps[prop.name]) { const value = configuredProps[prop.name as keyof ConfiguredProps]; if (value !== undefined) { propsToEnable[prop.name] = true; @@ -300,6 +300,7 @@ export const FormContextProvider = ({ component.key, configurableProps, configuredProps, + enabledOptionalProps, ]); // these validations are necessary because they might override PropInput for number case for instance @@ -388,6 +389,8 @@ export const FormContextProvider = ({ updateConfiguredPropsQueryDisabledIdx(configuredProps) }, [ component.key, + configurableProps, + enabledOptionalProps, ]); // Update queryDisabledIdx reactively when configuredProps changes. @@ -397,6 +400,8 @@ export const FormContextProvider = ({ updateConfiguredPropsQueryDisabledIdx(configuredProps); }, [ configuredProps, + configurableProps, + enabledOptionalProps, ]); useEffect(() => { @@ -442,6 +447,9 @@ export const FormContextProvider = ({ // Preserve label-value format from remote options dropdowns // Remote options store values as {__lv: {label: "...", value: ...}} // For multi-select fields, this will be an array of __lv objects + // IMPORTANT: Integer props with remote options (like IDs) can be stored in __lv format + // to preserve the display label. We only delete the value if it's NOT in __lv format + // AND not a number, which indicates invalid/corrupted data. const isLabelValue = value && typeof value === "object" && "__lv" in value; const isArrayOfLabelValues = Array.isArray(value) && value.length > 0 && value.every((item) => item && typeof item === "object" && "__lv" in item); @@ -461,6 +469,8 @@ export const FormContextProvider = ({ } }, [ configurableProps, + enabledOptionalProps, + configuredProps, ]); // clear all props on user change From c4f0f2a78a7cc5904e45578b4f377c0045d0f960 Mon Sep 17 00:00:00 2001 From: Danny Roosevelt Date: Mon, 27 Oct 2025 10:51:20 -0700 Subject: [PATCH 09/12] More PR feedback and code cleanup --- .../src/components/ControlSelect.tsx | 7 +-- .../connect-react/src/hooks/form-context.tsx | 8 +-- .../connect-react/src/utils/label-value.ts | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 packages/connect-react/src/utils/label-value.ts diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 433bfe4fb42ac..5cb3a25a1ae70 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -21,6 +21,7 @@ import { isOptionWithLabel, sanitizeOption, } from "../utils/type-guards"; +import { isLabelValueWrapped } from "../utils/label-value"; import { LoadMoreButton } from "./LoadMoreButton"; // XXX T and ConfigurableProp should be related @@ -91,14 +92,10 @@ export function ControlSelect({ return rawValue.map((o) => selectOptions.find((item) => item.value === o) || sanitizeOption(o as T)); } - } else if (rawValue && typeof rawValue === "object" && "__lv" in (rawValue as Record)) { + } else if (isLabelValueWrapped(rawValue)) { // Extract the actual option from __lv wrapper and sanitize to LV // Handle both single objects and arrays wrapped in __lv const lvContent = (rawValue as Record).__lv; - if (!lvContent) { - console.warn("Invalid __lv content:", rawValue); - return null; - } if (Array.isArray(lvContent)) { return lvContent.map((item) => sanitizeOption(item as T)); } diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 704f5039dab0a..dd4fc2ef32c1c 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -36,6 +36,7 @@ import { } from "../types"; import { resolveUserId } from "../utils/resolve-user-id"; import { isConfigurablePropOfType } from "../utils/type-guards"; +import { hasLabelValueFormat } from "../utils/label-value"; export type AnyFormFieldContext = Omit, "onChange"> & { onChange: (value: unknown) => void; @@ -300,7 +301,6 @@ export const FormContextProvider = ({ component.key, configurableProps, configuredProps, - enabledOptionalProps, ]); // these validations are necessary because they might override PropInput for number case for instance @@ -450,11 +450,7 @@ export const FormContextProvider = ({ // IMPORTANT: Integer props with remote options (like IDs) can be stored in __lv format // to preserve the display label. We only delete the value if it's NOT in __lv format // AND not a number, which indicates invalid/corrupted data. - const isLabelValue = value && typeof value === "object" && "__lv" in value; - const isArrayOfLabelValues = Array.isArray(value) && value.length > 0 && - value.every((item) => item && typeof item === "object" && "__lv" in item); - - if (!(isLabelValue || isArrayOfLabelValues)) { + if (!hasLabelValueFormat(value)) { delete newConfiguredProps[prop.name as keyof ConfiguredProps]; } else { newConfiguredProps[prop.name as keyof ConfiguredProps] = value; diff --git a/packages/connect-react/src/utils/label-value.ts b/packages/connect-react/src/utils/label-value.ts new file mode 100644 index 0000000000000..73ee1da0d4a71 --- /dev/null +++ b/packages/connect-react/src/utils/label-value.ts @@ -0,0 +1,57 @@ +/** + * Utilities for detecting and handling label-value (__lv) format + * used by Pipedream components to preserve display labels for option values + */ + +/** + * Checks if a value is wrapped in the __lv format + * @param value - The value to check + * @returns true if value is an object with __lv property containing valid data + * + * @example + * isLabelValueWrapped({ __lv: { label: "Option 1", value: 123 } }) // true + * isLabelValueWrapped({ __lv: null }) // false + * isLabelValueWrapped({ value: 123 }) // false + */ +export function isLabelValueWrapped(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (!("__lv" in value)) return false; + + const lvContent = (value as Record).__lv; + return lvContent != null; +} + +/** + * Checks if a value is an array of __lv wrapped objects + * @param value - The value to check + * @returns true if value is an array of valid __lv wrapped objects + * + * @example + * isArrayOfLabelValueWrapped([{ __lv: { label: "A", value: 1 } }]) // true + * isArrayOfLabelValueWrapped([]) // false + * isArrayOfLabelValueWrapped([{ value: 1 }]) // false + */ +export function isArrayOfLabelValueWrapped(value: unknown): boolean { + if (!Array.isArray(value)) return false; + if (value.length === 0) return false; + + return value.every((item) => + item && + typeof item === "object" && + "__lv" in item && + (item as Record).__lv != null); +} + +/** + * Checks if a value has the label-value format (either single or array) + * @param value - The value to check + * @returns true if value is in __lv format (single or array) + * + * @example + * hasLabelValueFormat({ __lv: { label: "A", value: 1 } }) // true + * hasLabelValueFormat([{ __lv: { label: "A", value: 1 } }]) // true + * hasLabelValueFormat({ value: 1 }) // false + */ +export function hasLabelValueFormat(value: unknown): boolean { + return isLabelValueWrapped(value) || isArrayOfLabelValueWrapped(value); +} From f14d1823503866a35ad97b8818a9325ef4f66fac Mon Sep 17 00:00:00 2001 From: Danny Roosevelt Date: Mon, 27 Oct 2025 10:57:42 -0700 Subject: [PATCH 10/12] Version and changelog --- packages/connect-react/CHANGELOG.md | 8 ++++++++ packages/connect-react/package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index c4ef3d82a7701..3387377bdba10 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -2,6 +2,14 @@ # Changelog +## [2.1.1] - 2025-10-27 + +### Fixed + +- Fixed optional props being removed when loading saved configurations +- Optional props with values now automatically display as enabled +- Improved handling of label-value format for remote options in multi-select fields + ## [2.1.0] - 2025-10-10 ### Added diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index 7d0810927d045..9b1ab702dfc7b 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "2.1.0", + "version": "2.1.1", "description": "Pipedream Connect library for React", "files": [ "dist" From a374a880a1a8c5ba51adccb9aa83a362f633f897 Mon Sep 17 00:00:00 2001 From: Danny Roosevelt Date: Mon, 27 Oct 2025 11:20:26 -0700 Subject: [PATCH 11/12] PR feedback --- .../src/components/ControlSelect.tsx | 32 ++++++--- .../connect-react/src/hooks/form-context.tsx | 70 ++++++++----------- 2 files changed, 53 insertions(+), 49 deletions(-) diff --git a/packages/connect-react/src/components/ControlSelect.tsx b/packages/connect-react/src/components/ControlSelect.tsx index 5cb3a25a1ae70..8f11a0851b318 100644 --- a/packages/connect-react/src/components/ControlSelect.tsx +++ b/packages/connect-react/src/components/ControlSelect.tsx @@ -16,12 +16,18 @@ import CreatableSelect from "react-select/creatable"; import type { BaseReactSelectProps } from "../hooks/customization-context"; import { useCustomize } from "../hooks/customization-context"; import { useFormFieldContext } from "../hooks/form-field-context"; -import { LabelValueOption } from "../types"; +import { + LabelValueOption, + RawPropOption, +} from "../types"; import { isOptionWithLabel, sanitizeOption, } from "../utils/type-guards"; -import { isLabelValueWrapped } from "../utils/label-value"; +import { + isArrayOfLabelValueWrapped, + isLabelValueWrapped, +} from "../utils/label-value"; import { LoadMoreButton } from "./LoadMoreButton"; // XXX T and ConfigurableProp should be related @@ -86,20 +92,26 @@ export function ControlSelect({ return null; } + // Handle __lv-wrapped values (single object or array) returned from remote options + if (isLabelValueWrapped(rawValue)) { + const lvContent = (rawValue as Record).__lv; + if (Array.isArray(lvContent)) { + return lvContent.map((item) => sanitizeOption(item as unknown as RawPropOption)); + } + return sanitizeOption(lvContent as unknown as RawPropOption); + } + + if (isArrayOfLabelValueWrapped(rawValue)) { + return (rawValue as Array>).map((item) => + sanitizeOption(item as unknown as RawPropOption)); + } + if (Array.isArray(rawValue)) { // if simple, make lv (XXX combine this with other place this happens) if (!isOptionWithLabel(rawValue[0])) { return rawValue.map((o) => selectOptions.find((item) => item.value === o) || sanitizeOption(o as T)); } - } else if (isLabelValueWrapped(rawValue)) { - // Extract the actual option from __lv wrapper and sanitize to LV - // Handle both single objects and arrays wrapped in __lv - const lvContent = (rawValue as Record).__lv; - if (Array.isArray(lvContent)) { - return lvContent.map((item) => sanitizeOption(item as T)); - } - return sanitizeOption(lvContent as T); } else if (!isOptionWithLabel(rawValue)) { const lvOptions = selectOptions?.[0] && isOptionWithLabel(selectOptions[0]); if (lvOptions) { diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index dd4fc2ef32c1c..ce322c6ac126d 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -277,32 +277,6 @@ export const FormContextProvider = ({ reloadPropIdx, ]); - // Auto-enable optional props that have values in configuredProps - // This ensures optional fields with saved values are shown when mounting with pre-configured props - useEffect(() => { - const propsToEnable: Record = {}; - - for (const prop of configurableProps) { - if (prop.optional && !enabledOptionalProps[prop.name]) { - const value = configuredProps[prop.name as keyof ConfiguredProps]; - if (value !== undefined) { - propsToEnable[prop.name] = true; - } - } - } - - if (Object.keys(propsToEnable).length > 0) { - setEnabledOptionalProps((prev) => ({ - ...prev, - ...propsToEnable, - })); - } - }, [ - component.key, - configurableProps, - configuredProps, - ]); - // these validations are necessary because they might override PropInput for number case for instance // so can't rely on that base control form validation const propErrors = (prop: ConfigurableProp, value: unknown): string[] => { @@ -382,6 +356,13 @@ export const FormContextProvider = ({ setErrors(_errors); }; + const preserveIntegerValue = (prop: ConfigurableProp, value: unknown) => { + if (prop.type !== "integer" || typeof value === "number") { + return value; + } + return hasLabelValueFormat(value) ? value : undefined; + }; + useEffect(() => { // Initialize queryDisabledIdx using actual configuredProps (includes parent-passed values in controlled mode) // instead of _configuredProps which starts empty. This ensures that when mounting with pre-configured @@ -443,20 +424,14 @@ export const FormContextProvider = ({ newConfiguredProps[prop.name as keyof ConfiguredProps] = prop.default as any; // eslint-disable-line @typescript-eslint/no-explicit-any } } else { - if (prop.type === "integer" && typeof value !== "number") { - // Preserve label-value format from remote options dropdowns - // Remote options store values as {__lv: {label: "...", value: ...}} - // For multi-select fields, this will be an array of __lv objects - // IMPORTANT: Integer props with remote options (like IDs) can be stored in __lv format - // to preserve the display label. We only delete the value if it's NOT in __lv format - // AND not a number, which indicates invalid/corrupted data. - if (!hasLabelValueFormat(value)) { - delete newConfiguredProps[prop.name as keyof ConfiguredProps]; - } else { - newConfiguredProps[prop.name as keyof ConfiguredProps] = value; - } + // Preserve label-value format from remote options dropdowns for integer props. + // Remote options store values as {__lv: {label: "...", value: ...}} (or arrays of __lv objects). + // For integer props we drop anything that isn't number or label-value formatted to avoid corrupt data. + const preservedValue = preserveIntegerValue(prop, value); + if (preservedValue === undefined) { + delete newConfiguredProps[prop.name as keyof ConfiguredProps]; } else { - newConfiguredProps[prop.name as keyof ConfiguredProps] = value; + newConfiguredProps[prop.name as keyof ConfiguredProps] = preservedValue as any; // eslint-disable-line @typescript-eslint/no-explicit-any } } } @@ -533,6 +508,23 @@ export const FormContextProvider = ({ setEnabledOptionalProps(newEnabledOptionalProps); }; + // Auto-enable optional props with saved values so dependent dynamic props reload correctly + useEffect(() => { + for (const prop of configurableProps) { + if (!prop.optional) continue; + if (enabledOptionalProps[prop.name]) continue; + const value = configuredProps[prop.name as keyof ConfiguredProps]; + if (value === undefined) continue; + optionalPropSetEnabled(prop, true); + } + }, [ + component.key, + configurableProps, + configuredProps, + enabledOptionalProps, + optionalPropSetEnabled, + ]); + const checkPropsNeedConfiguring = () => { const _propsNeedConfiguring = [] for (const prop of configurableProps) { From e2962a15271eca23a92d6b592d5c9866b345f02e Mon Sep 17 00:00:00 2001 From: Danny Roosevelt Date: Mon, 27 Oct 2025 11:25:20 -0700 Subject: [PATCH 12/12] Update form-context.tsx --- packages/connect-react/src/hooks/form-context.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index ce322c6ac126d..39acafdf2a37f 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -360,7 +360,9 @@ export const FormContextProvider = ({ if (prop.type !== "integer" || typeof value === "number") { return value; } - return hasLabelValueFormat(value) ? value : undefined; + return hasLabelValueFormat(value) + ? value + : undefined; }; useEffect(() => {