This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 613871ed363 fix(native-filters): stabilize DefaultValue's filterState
object identity (#43859)
613871ed363 is described below
commit 613871ed3633ee0b4dcba5a237dd50a7239070d5
Author: Kunal <[email protected]>
AuthorDate: Wed Sep 9 10:21:20 2026 +0530
fix(native-filters): stabilize DefaultValue's filterState object identity
(#43859)
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../FiltersConfigForm/DefaultValue.test.tsx | 118 +++++++++++++++++++++
.../FiltersConfigForm/DefaultValue.tsx | 25 ++++-
2 files changed, 138 insertions(+), 5 deletions(-)
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.test.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.test.tsx
new file mode 100644
index 00000000000..a3e0ebbdf14
--- /dev/null
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 { render } from 'spec/helpers/testing-library';
+import DefaultValue from './DefaultValue';
+
+const capturedFilterStates: unknown[] = [];
+
+jest.mock('@superset-ui/core', () => {
+ const original = jest.requireActual('@superset-ui/core');
+ return {
+ ...original,
+ SuperChart: (props: Record<string, unknown>) => {
+ capturedFilterStates.push(props.filterState);
+ return <div data-test="mock-super-chart" />;
+ },
+ };
+});
+
+const FILTER_ID = 'filter-1';
+
+// A minimal stand-in for antd's FormInstance: DefaultValue only ever calls
+// `form.getFieldValue('filters')`.
+const makeForm = (filtersValue: Record<string, unknown>) => ({
+ getFieldValue: (name: string) =>
+ name === 'filters' ? filtersValue : undefined,
+});
+
+const baseProps = {
+ hasDefaultValue: true,
+ filterId: FILTER_ID,
+ setDataMask: jest.fn(),
+ hasDataset: true,
+ formData: { filterType: 'filter_select' } as any,
+ enableNoResults: true,
+};
+
+beforeEach(() => {
+ capturedFilterStates.length = 0;
+});
+
+test('keeps the same filterState object reference across renders that do not
change its contents', () => {
+ // Same underlying filterState object on every call to getFieldValue,
+ // exactly like re-opening the form without touching the field.
+ const filterState = { value: [1, 2], label: 'One, Two' };
+ const formFilter = {
+ filterType: 'filter_select',
+ defaultValueQueriesData: [{ data: [{ col: 1 }, { col: 2 }] }],
+ defaultDataMask: { filterState },
+ };
+
+ const { rerender } = render(
+ <DefaultValue
+ {...baseProps}
+ form={makeForm({ [FILTER_ID]: formFilter }) as any}
+ />,
+ );
+
+ // A parent re-render triggered by something unrelated (e.g. the config
+ // modal's forceUpdate() after an ownState-only dataMask change while the
+ // user is typing/searching) with the exact same underlying form data.
+ rerender(
+ <DefaultValue
+ {...baseProps}
+ form={makeForm({ [FILTER_ID]: formFilter }) as any}
+ />,
+ );
+
+ expect(capturedFilterStates).toHaveLength(2);
+ // Before the fix, DefaultValue spread `filterState` into a brand new
+ // object literal on every render, so this would be two distinct objects
+ // (even though their contents matched) — and the underlying Select
+ // resets its selection whenever the object it receives changes identity.
+ expect(capturedFilterStates[0]).toBe(capturedFilterStates[1]);
+});
+
+test('produces a new filterState object once the underlying value actually
changes', () => {
+ const formFilterWithValue = (value: number[]) => ({
+ filterType: 'filter_select',
+ defaultValueQueriesData: [{ data: [{ col: 1 }, { col: 2 }] }],
+ defaultDataMask: { filterState: { value, label: value.join(', ') } },
+ });
+
+ const { rerender } = render(
+ <DefaultValue
+ {...baseProps}
+ form={makeForm({ [FILTER_ID]: formFilterWithValue([1, 2]) }) as any}
+ />,
+ );
+
+ rerender(
+ <DefaultValue
+ {...baseProps}
+ form={makeForm({ [FILTER_ID]: formFilterWithValue([1, 2, 3]) }) as any}
+ />,
+ );
+
+ expect(capturedFilterStates).toHaveLength(2);
+ expect(capturedFilterStates[0]).not.toBe(capturedFilterStates[1]);
+ expect((capturedFilterStates[1] as { value: number[] }).value).toEqual([
+ 1, 2, 3,
+ ]);
+});
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx
index 3d3d5e4d77f..996179533fd 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx
@@ -62,6 +62,25 @@ const DefaultValue: FC<DefaultValueProps> = ({
const value = formFilter?.defaultDataMask?.filterState?.value;
const isMissingRequiredValue =
hasDefaultValue && (value === null || value === undefined);
+ const baseFilterState = formFilter?.defaultDataMask?.filterState;
+
+ // Every DefaultValue render used to spread `baseFilterState` into a brand
+ // new object literal here, so the `filterState` prop the underlying Select
+ // receives got a new identity on every render (e.g. while the user is
+ // typing/searching), even when its actual contents were unchanged. Select
+ // resets its internal selection whenever that identity changes, which is
+ // what wiped out already-chosen default values. Memoizing on the
+ // underlying reference plus the two derived validation fields keeps the
+ // object stable across renders that don't actually change anything.
+ const filterState = useMemo(
+ () => ({
+ ...baseFilterState,
+ validateMessage: isMissingRequiredValue && t('Value is required'),
+ validateStatus: isMissingRequiredValue && 'error',
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [baseFilterState, isMissingRequiredValue],
+ );
return loading ? (
<Loading position="inline-centered" />
@@ -76,11 +95,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
chartType={chartType}
hooks={{ setDataMask }}
enableNoResults={enableNoResults}
- filterState={{
- ...formFilter?.defaultDataMask?.filterState,
- validateMessage: isMissingRequiredValue && t('Value is required'),
- validateStatus: isMissingRequiredValue && 'error',
- }}
+ filterState={filterState}
/>
);
};