From 1d9550bde6b9b5275a794d86ce7592a0ff52f72f Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 9 Feb 2026 15:02:17 +0530 Subject: [PATCH 1/6] fix: show spinner on ActiveText retrigger --- .../active-text-retrigger-spinner.md | 5 ++++ .../src/utils/useFetch.ts | 23 ++++++++++++++++++- .../src/utils/useFetchAndEvaluate.ts | 11 +++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md diff --git a/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md new file mode 100644 index 0000000000..319eaafa9a --- /dev/null +++ b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +--- + +Show ActiveText spinner immediately on retrigger changes to avoid stale text during debounce. diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index 8d71e06250..c35900340c 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -16,7 +16,7 @@ import { useApi, fetchApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { UiProps } from '../uiPropTypes'; import { getErrorMessage } from './errorUtils'; import { useEvaluateTemplate } from './evaluateTemplate'; @@ -68,6 +68,27 @@ export const useFetch = ( setError, }); + useEffect(() => { + if ( + !fetchUrl || + !evaluatedFetchUrl || + !evaluatedRequestInit || + !retrigger + ) { + setLoading(false); + return; + } + + if (!areRetriggerDependenciesSatisfied(retrigger)) { + setLoading(false); + return; + } + + // Mark loading immediately when a retrigger change is detected so widgets + // can show the spinner during the debounce window before the fetch starts. + setLoading(true); + }, [fetchUrl, evaluatedFetchUrl, evaluatedRequestInit, retrigger]); + useDebounce( () => { if ( diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts index bf7f0681ac..883ea47717 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts @@ -59,6 +59,17 @@ export const useFetchAndEvaluate = ( const [error, setError] = useState(); const [loading, setLoading] = React.useState(true); const [resultText, setResultText] = React.useState(); + + useEffect(() => { + if (!hasRetrigger || !retrigger || waitingForRetrigger) { + return; + } + + // Show spinner immediately on retrigger changes, even before debounce/fetch. + if (retriggerSatisfied) { + setLoading(true); + } + }, [hasRetrigger, retrigger, retriggerSatisfied, waitingForRetrigger]); useDebounce( () => { const evaluate = async () => { From 30c7c1e2b4fbec6b091d06ae3cb9f771ba702b46 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Thu, 12 Feb 2026 11:40:44 +0530 Subject: [PATCH 2/6] fix(orchestrator-form-widgets): keep spinner until ActiveText eval completes --- .../src/utils/useFetch.ts | 19 ++++++++++------- .../src/utils/useFetchAndEvaluate.ts | 21 ++++++++++++++++--- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index c35900340c..62ec6c59a4 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -68,13 +68,12 @@ export const useFetch = ( setError, }); + const hasFetchInputs = + !!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger; + + // Set loading immediately on dependency changes so UI shows a spinner during debounce. useEffect(() => { - if ( - !fetchUrl || - !evaluatedFetchUrl || - !evaluatedRequestInit || - !retrigger - ) { + if (!hasFetchInputs) { setLoading(false); return; } @@ -87,7 +86,13 @@ export const useFetch = ( // Mark loading immediately when a retrigger change is detected so widgets // can show the spinner during the debounce window before the fetch starts. setLoading(true); - }, [fetchUrl, evaluatedFetchUrl, evaluatedRequestInit, retrigger]); + }, [ + hasFetchInputs, + fetchUrl, + evaluatedFetchUrl, + evaluatedRequestInit, + retrigger, + ]); useDebounce( () => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts index 883ea47717..9e915ff27b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetchAndEvaluate.ts @@ -60,16 +60,31 @@ export const useFetchAndEvaluate = ( const [loading, setLoading] = React.useState(true); const [resultText, setResultText] = React.useState(); + // Keep spinner visible during the debounce window after fetch/retrigger changes. useEffect(() => { if (!hasRetrigger || !retrigger || waitingForRetrigger) { return; } - // Show spinner immediately on retrigger changes, even before debounce/fetch. - if (retriggerSatisfied) { + if (fetchError) { + setLoading(false); + return; + } + + // Show spinner immediately on retrigger changes and after data updates, + // even before the debounced evaluation completes. + if (retriggerSatisfied || fetchLoading || data !== undefined) { setLoading(true); } - }, [hasRetrigger, retrigger, retriggerSatisfied, waitingForRetrigger]); + }, [ + hasRetrigger, + retrigger, + retriggerSatisfied, + waitingForRetrigger, + fetchError, + fetchLoading, + data, + ]); useDebounce( () => { const evaluate = async () => { From 807414f55f5ce585df0f7f32f05498cc65595fa4 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 25 Feb 2026 16:15:31 +0530 Subject: [PATCH 3/6] Merge upstream/main Co-authored-by: Cursor --- .../docs/orchestratorFormWidgets.md | 2 ++ .../src/uiPropTypes.ts | 1 + .../src/widgets/ActiveDropdown.tsx | 26 ++++++++++++++++++- .../src/widgets/ActiveMultiSelect.tsx | 26 +++++++++++++++++++ .../src/widgets/ActiveTextInput.tsx | 26 ++++++++++++++++++- 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index ba6b6ca5d2..248c4a31bb 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -521,6 +521,7 @@ The widget supports the following `ui:props` (for detailed information on each, - `fetch:headers`: HTTP headers for the fetch request - `fetch:body`: HTTP body for the fetch request - `fetch:retrigger`: Array of field paths that trigger a refetch when their values change +- `fetch:clearOnRetrigger`: Clears the field value when retrigger dependencies change ## Content of `ui:props` @@ -540,6 +541,7 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https | fetch:method | HTTP method to use. The default is GET. | GET, POST (So far no identified use-case for PUT or DELETE) | | fetch:body | An object representing the body of an HTTP POST request. Not used with the GET method. Property value can be a string template or an array of strings. templates. | `{“foo”: “bar $${{identityApi.token}}”, "myArray": ["constant", "$${{current.solutionName}}"]}` | | fetch:retrigger | An array of keys/key families as described in the Backstage API Exposed Parts. If the value referenced by any key from this list is changed, the fetch is triggered. | `["current.solutionName", "identityApi.profileName"]` | +| fetch:clearOnRetrigger | When set to `true`, clears the field value as soon as any `fetch:retrigger` dependency changes, before the fetch completes. Useful to avoid stale values while refetching. | `true`, `false` (default: `false`) | | fetch:error:ignoreUnready | When set to `true`, suppresses fetch error display until all `fetch:retrigger` dependencies have non-empty values. This is useful when fetch depends on other fields that are not filled yet, preventing expected errors from being displayed during initial load. | `true`, `false` (default: `false`) | | fetch:error:silent | When set to `true`, suppresses fetch error display when the fetch request returns a non-OK status (4xx/5xx). Use this when you want to handle error states via conditional UI instead of showing the widget error. | `true`, `false` (default: `false`) | | fetch:skipInitialValue | When set to `true`, prevents applying the initial value from `fetch:response:value`, keeping the field empty until the user selects or types a value. | `true`, `false` (default: `false`) | diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts index b74a2d5bf0..385f932d3f 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts @@ -25,6 +25,7 @@ export type UiProps = { 'fetch:headers'?: Record; 'fetch:body'?: Record; 'fetch:retrigger'?: string[]; + 'fetch:clearOnRetrigger'?: boolean; 'fetch:error:ignoreUnready'?: boolean; 'fetch:error:silent'?: boolean; 'fetch:skipInitialValue'?: boolean; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index fb41b98a71..e43a6f581a 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { makeStyles } from 'tss-react/mui'; import { Widget } from '@rjsf/utils'; import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; +import isEqual from 'lodash/isEqual'; import CircularProgress from '@mui/material/CircularProgress'; import FormControl from '@mui/material/FormControl'; @@ -79,6 +80,7 @@ export const ActiveDropdown: Widget< const hasStaticDefault = typeof staticDefault === 'string'; const staticDefaultValue = hasStaticDefault ? staticDefault : undefined; const skipInitialValue = uiProps['fetch:skipInitialValue'] === true; + const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; const [localError, setLocalError] = useState( !labelSelector || !valueSelector @@ -97,6 +99,9 @@ export const ActiveDropdown: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); + const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -159,6 +164,25 @@ export const ActiveDropdown: Widget< [onChange, id, setIsChangedByUser], ); + useEffect(() => { + if (!clearOnRetrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + if (!retrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + const prev = prevRetriggerRef.current; + if (prev && !isEqual(prev, retrigger)) { + handleChange('', false); + } + + prevRetriggerRef.current = retrigger; + }, [clearOnRetrigger, retrigger, handleChange]); + // Set default value from fetched options // Priority: selector default (if valid option) > static default (if valid) > first fetched option // Note: Static defaults are applied at form initialization level (in OrchestratorForm) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 7653161bdd..6bc3c46cc1 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -18,6 +18,7 @@ import { SyntheticEvent, useEffect, useMemo, + useRef, useState, } from 'react'; import clsx from 'clsx'; @@ -34,6 +35,7 @@ import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; import { Widget } from '@rjsf/utils'; +import isEqual from 'lodash/isEqual'; import { useTemplateUnitEvaluator, @@ -85,6 +87,7 @@ export const ActiveMultiSelect: Widget< const allowNewItems = uiProps['ui:allowNewItems'] === true; const staticDefault = uiProps['fetch:response:default']; const skipInitialValue = uiProps['fetch:skipInitialValue'] === true; + const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; const staticDefaultValues = Array.isArray(staticDefault) ? (staticDefault as string[]) : undefined; @@ -135,6 +138,9 @@ export const ActiveMultiSelect: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); + const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -145,6 +151,26 @@ export const ActiveMultiSelect: Widget< handleFetchEnded, ); + useEffect(() => { + if (!clearOnRetrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + if (!retrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + const prev = prevRetriggerRef.current; + if (prev && !isEqual(prev, retrigger)) { + setInProgressItem(''); + onChange([]); + } + + prevRetriggerRef.current = retrigger; + }, [clearOnRetrigger, retrigger, onChange]); + // Process fetch results // Note: Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index 5af03f7542..3b2faec5b2 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { JsonObject } from '@backstage/types'; import { Widget } from '@rjsf/utils'; @@ -40,6 +40,7 @@ import { } from '../utils'; import { ErrorText } from './ErrorText'; import { UiProps } from '../uiPropTypes'; +import isEqual from 'lodash/isEqual'; const useStyles = makeStyles()((theme: Theme) => ({ autocompleteOptionSelected: { @@ -73,6 +74,7 @@ export const ActiveTextInput: Widget< const hasStaticDefault = typeof staticDefault === 'string'; const skipInitialValue = uiProps['fetch:skipInitialValue'] === true; const hasFetchUrl = !!uiProps['fetch:url']; + const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; // If fetch:url is configured, either fetch:response:value OR fetch:response:default should be set // to provide meaningful behavior. Without fetch:url, the widget works as a plain text input. @@ -92,6 +94,9 @@ export const ActiveTextInput: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); + const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -113,6 +118,25 @@ export const ActiveTextInput: Widget< [onChange, id, setIsChangedByUser], ); + useEffect(() => { + if (!clearOnRetrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + if (!retrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + const prev = prevRetriggerRef.current; + if (prev && !isEqual(prev, retrigger)) { + handleChange('', false); + } + + prevRetriggerRef.current = retrigger; + }, [clearOnRetrigger, retrigger, handleChange]); + // Process fetch results - only override if fetch returns a non-empty value // Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { From 077296f82a0aeb66e92bb928fdcfc951d8605f7f Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 23 Feb 2026 13:24:09 +0530 Subject: [PATCH 4/6] chore(changeset): mention clearOnRetrigger Document the new fetch:clearOnRetrigger behavior in the existing changeset for the ActiveText retrigger spinner update. Co-authored-by: Cursor --- .../orchestrator/.changeset/active-text-retrigger-spinner.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md index 319eaafa9a..8f53b66310 100644 --- a/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md +++ b/workspaces/orchestrator/.changeset/active-text-retrigger-spinner.md @@ -2,4 +2,4 @@ '@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch --- -Show ActiveText spinner immediately on retrigger changes to avoid stale text during debounce. +Show ActiveText spinner immediately on retrigger changes to avoid stale text during debounce. Add fetch:clearOnRetrigger to clear widget values when dependencies change. From 442666e258e9bc1fcbd42b9eab2ab3b23fa2e809 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 23 Feb 2026 14:56:54 +0530 Subject: [PATCH 5/6] refactor(orchestrator-form-widgets): dedupe clearOnRetrigger Extract shared clear-on-retrigger behavior into a reusable hook and reuse it across ActiveTextInput, ActiveDropdown, and ActiveMultiSelect. Co-authored-by: Cursor --- .../src/utils/index.ts | 1 + .../src/utils/useClearOnRetrigger.ts | 52 +++++++++++++++++++ .../src/widgets/ActiveDropdown.tsx | 32 ++++-------- .../src/widgets/ActiveMultiSelect.tsx | 34 ++++-------- .../src/widgets/ActiveTextInput.tsx | 32 ++++-------- 5 files changed, 84 insertions(+), 67 deletions(-) create mode 100644 workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts index 5899c1b647..1984e8ee0b 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts @@ -24,3 +24,4 @@ export * from './useFetchAndEvaluate'; export * from './applySelector'; export * from './useProcessingState'; export * from './resolveDropdownDefault'; +export * from './useClearOnRetrigger'; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts new file mode 100644 index 0000000000..2960e8b6bd --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts @@ -0,0 +1,52 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect, useRef } from 'react'; +import isEqual from 'lodash/isEqual'; + +type UseClearOnRetriggerArgs = { + enabled: boolean; + retrigger: (string | undefined)[] | undefined; + onClear: () => void; +}; + +export const useClearOnRetrigger = ({ + enabled, + retrigger, + onClear, +}: UseClearOnRetriggerArgs) => { + const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); + + useEffect(() => { + if (!enabled) { + prevRetriggerRef.current = retrigger; + return; + } + + if (!retrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + const prev = prevRetriggerRef.current; + if (prev && !isEqual(prev, retrigger)) { + onClear(); + } + + prevRetriggerRef.current = retrigger; + }, [enabled, retrigger, onClear]); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx index e43a6f581a..200c02e259 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveDropdown.tsx @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import clsx from 'clsx'; import { makeStyles } from 'tss-react/mui'; import { Widget } from '@rjsf/utils'; import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; -import isEqual from 'lodash/isEqual'; import CircularProgress from '@mui/material/CircularProgress'; import FormControl from '@mui/material/FormControl'; @@ -35,6 +34,7 @@ import { applySelectorArray, resolveDropdownDefault, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -99,9 +99,6 @@ export const ActiveDropdown: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); - const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( - retrigger, - ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -164,24 +161,15 @@ export const ActiveDropdown: Widget< [onChange, id, setIsChangedByUser], ); - useEffect(() => { - if (!clearOnRetrigger) { - prevRetriggerRef.current = retrigger; - return; - } + const handleClear = useCallback(() => { + handleChange('', false); + }, [handleChange]); - if (!retrigger) { - prevRetriggerRef.current = retrigger; - return; - } - - const prev = prevRetriggerRef.current; - if (prev && !isEqual(prev, retrigger)) { - handleChange('', false); - } - - prevRetriggerRef.current = retrigger; - }, [clearOnRetrigger, retrigger, handleChange]); + useClearOnRetrigger({ + enabled: clearOnRetrigger, + retrigger, + onClear: handleClear, + }); // Set default value from fetched options // Priority: selector default (if valid option) > static default (if valid) > first fetched option diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx index 6bc3c46cc1..5f505a4e26 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveMultiSelect.tsx @@ -16,9 +16,9 @@ import { KeyboardEvent, SyntheticEvent, + useCallback, useEffect, useMemo, - useRef, useState, } from 'react'; import clsx from 'clsx'; @@ -35,7 +35,6 @@ import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { OrchestratorFormContextProps } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api'; import { Widget } from '@rjsf/utils'; -import isEqual from 'lodash/isEqual'; import { useTemplateUnitEvaluator, @@ -43,6 +42,7 @@ import { useFetch, useRetriggerEvaluate, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { UiProps } from '../uiPropTypes'; import { ErrorText } from './ErrorText'; @@ -138,9 +138,6 @@ export const ActiveMultiSelect: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); - const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( - retrigger, - ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -151,25 +148,16 @@ export const ActiveMultiSelect: Widget< handleFetchEnded, ); - useEffect(() => { - if (!clearOnRetrigger) { - prevRetriggerRef.current = retrigger; - return; - } - - if (!retrigger) { - prevRetriggerRef.current = retrigger; - return; - } - - const prev = prevRetriggerRef.current; - if (prev && !isEqual(prev, retrigger)) { - setInProgressItem(''); - onChange([]); - } + const handleClear = useCallback(() => { + setInProgressItem(''); + onChange([]); + }, [onChange]); - prevRetriggerRef.current = retrigger; - }, [clearOnRetrigger, retrigger, onChange]); + useClearOnRetrigger({ + enabled: clearOnRetrigger, + retrigger, + onClear: handleClear, + }); // Process fetch results // Note: Static defaults are applied at form initialization level (in OrchestratorForm) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index 3b2faec5b2..c8810bbbe6 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import clsx from 'clsx'; import { JsonObject } from '@backstage/types'; import { Widget } from '@rjsf/utils'; @@ -37,10 +37,10 @@ import { applySelectorArray, applySelectorString, useProcessingState, + useClearOnRetrigger, } from '../utils'; import { ErrorText } from './ErrorText'; import { UiProps } from '../uiPropTypes'; -import isEqual from 'lodash/isEqual'; const useStyles = makeStyles()((theme: Theme) => ({ autocompleteOptionSelected: { @@ -94,9 +94,6 @@ export const ActiveTextInput: Widget< /* This is safe retype, since proper checking of input value is done in the useRetriggerEvaluate() hook */ uiProps['fetch:retrigger'] as string[], ); - const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( - retrigger, - ); const { data, error, loading } = useFetch(formData ?? {}, uiProps, retrigger); @@ -118,24 +115,15 @@ export const ActiveTextInput: Widget< [onChange, id, setIsChangedByUser], ); - useEffect(() => { - if (!clearOnRetrigger) { - prevRetriggerRef.current = retrigger; - return; - } + const handleClear = useCallback(() => { + handleChange('', false); + }, [handleChange]); - if (!retrigger) { - prevRetriggerRef.current = retrigger; - return; - } - - const prev = prevRetriggerRef.current; - if (prev && !isEqual(prev, retrigger)) { - handleChange('', false); - } - - prevRetriggerRef.current = retrigger; - }, [clearOnRetrigger, retrigger, handleChange]); + useClearOnRetrigger({ + enabled: clearOnRetrigger, + retrigger, + onClear: handleClear, + }); // Process fetch results - only override if fetch returns a non-empty value // Static defaults are applied at form initialization level (in OrchestratorForm) From 2d96cc0a36c9959b71a0d193cc786477b8fe24ed Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 25 Feb 2026 15:49:32 +0530 Subject: [PATCH 6/6] fix(orchestrator-form-widgets): guard retrigger races Ignore stale fetch responses when retrigger values change and avoid reapplying cached data while a retriggered fetch is loading. Use layout effect for clearOnRetrigger to reduce UI flicker. Co-authored-by: Cursor --- .../src/utils/useClearOnRetrigger.ts | 4 +- .../src/utils/useFetch.ts | 52 +++++++++++++++++-- .../src/widgets/ActiveTextInput.tsx | 6 +++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts index 2960e8b6bd..ef961535d4 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useClearOnRetrigger.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEffect, useRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import isEqual from 'lodash/isEqual'; type UseClearOnRetriggerArgs = { @@ -31,7 +31,7 @@ export const useClearOnRetrigger = ({ retrigger, ); - useEffect(() => { + useLayoutEffect(() => { if (!enabled) { prevRetriggerRef.current = retrigger; return; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index 62ec6c59a4..8d16a8fb81 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -16,7 +16,7 @@ import { useApi, fetchApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { UiProps } from '../uiPropTypes'; import { getErrorMessage } from './errorUtils'; import { useEvaluateTemplate } from './evaluateTemplate'; @@ -24,6 +24,7 @@ import { useRequestInit } from './useRequestInit'; import { useRetriggerEvaluate } from './useRetriggerEvaluate'; import { useDebounce } from 'react-use'; import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants'; +import isEqual from 'lodash/isEqual'; /** * Checks if all fetch:retrigger dependencies have non-empty values. @@ -55,6 +56,7 @@ export const useFetch = ( const fetchUrl = uiProps['fetch:url']; const skipErrorWhenDepsEmpty = uiProps['fetch:error:ignoreUnready'] === true; + const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; const evaluatedRequestInit = useRequestInit({ uiProps, prefix: 'fetch', @@ -67,6 +69,37 @@ export const useFetch = ( formData, setError, }); + const prevRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); + const latestRetriggerRef = useRef<(string | undefined)[] | undefined>( + retrigger, + ); + const requestIdRef = useRef(0); + + useEffect(() => { + latestRetriggerRef.current = retrigger; + }, [retrigger]); + + useEffect(() => { + if (!clearOnRetrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + if (!retrigger) { + prevRetriggerRef.current = retrigger; + return; + } + + const prev = prevRetriggerRef.current; + if (prev && !isEqual(prev, retrigger)) { + setData(undefined); + setError(undefined); + } + + prevRetriggerRef.current = retrigger; + }, [clearOnRetrigger, retrigger]); const hasFetchInputs = !!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger; @@ -107,6 +140,8 @@ export const useFetch = ( } const fetchData = async () => { + const requestId = ++requestIdRef.current; + const retriggerSnapshot = retrigger; try { setError(undefined); if (typeof evaluatedFetchUrl !== 'string') { @@ -142,14 +177,23 @@ export const useFetch = ( throw new Error('JSON object expected'); } - setData(responseData); + if ( + requestId === requestIdRef.current && + isEqual(retriggerSnapshot, latestRetriggerRef.current) + ) { + setData(responseData); + } } catch (err) { const prefix = `Failed to fetch data for url ${fetchUrl}.`; // eslint-disable-next-line no-console console.error(prefix, err); - setError(getErrorMessage(prefix, err)); + if (requestId === requestIdRef.current) { + setError(getErrorMessage(prefix, err)); + } } finally { - setLoading(false); + if (requestId === requestIdRef.current) { + setLoading(false); + } } }; fetchData(); diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx index c8810bbbe6..8fab03bfa3 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/widgets/ActiveTextInput.tsx @@ -128,6 +128,10 @@ export const ActiveTextInput: Widget< // Process fetch results - only override if fetch returns a non-empty value // Static defaults are applied at form initialization level (in OrchestratorForm) useEffect(() => { + if (clearOnRetrigger && loading) { + return; + } + if (!data) { return; } @@ -171,6 +175,8 @@ export const ActiveTextInput: Widget< isChangedByUser, skipInitialValue, wrapProcessing, + clearOnRetrigger, + loading, ]); const shouldShowFetchError = uiProps['fetch:error:silent'] !== true;