You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/14 01:23:51 UTC

(superset) branch master updated: fix(semantic-views): hide stale dependent options while they refresh (#44995)

This is an automated email from the ASF dual-hosted git repository.

aminghadersohi 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 8175e0ecc7b fix(semantic-views): hide stale dependent options while they refresh (#44995)
8175e0ecc7b is described below

commit 8175e0ecc7b25f4c54dac0c0810763b4f2c1ae4e
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Oct 13 21:23:43 2026 -0400

    fix(semantic-views): hide stale dependent options while they refresh (#44995)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    Co-authored-by: Claude Opus 5.5 (1M context) <no...@anthropic.com>
---
 .../using-superset/semantic-layer-connections.mdx  |  16 +
 .../semanticLayers/MultiEnumControl.test.tsx       |  36 +
 .../semanticLayers/jsonFormsAntdRenderers.test.tsx |  17 +-
 .../features/semanticLayers/jsonFormsHelpers.tsx   |  16 +-
 .../semanticViews/AddSemanticViewModal.test.tsx    | 786 +++++++++++++++++++--
 .../semanticViews/AddSemanticViewModal.tsx         | 269 ++++---
 6 files changed, 998 insertions(+), 142 deletions(-)

diff --git a/docs/docs/using-superset/semantic-layer-connections.mdx b/docs/docs/using-superset/semantic-layer-connections.mdx
index 1c50184cc08..b803725973a 100644
--- a/docs/docs/using-superset/semantic-layer-connections.mdx
+++ b/docs/docs/using-superset/semantic-layer-connections.mdx
@@ -45,3 +45,19 @@ generic fallback when no reason is available. Saved secrets remain masked while
 editing; configuration changes may require re-entering masked values, as explained
 by the server's validation message. Changing connection dependencies can clear
 dependent selections, so check those fields before saving.
+
+## Dependent source selectors
+
+In **Add Semantic View**, changing a selected Database makes its dependent
+Schema choices unavailable while fresh options load, including the debounce
+interval. A matching response preserves selections that remain valid and clears
+those no longer offered. If loading fails, the form retains your selections but
+keeps stale choices unavailable; a later form edit can retry once for that
+dependency state. Changing the Database requests choices for the new state.
+After a failed refresh, saving stays blocked only while a single-choice dependent
+field has choices loaded.
+If no dependent field has its prerequisites filled, clearing a required source
+keeps dependent choices unavailable until that source is filled. Clearing an
+optional dependency can load choices for the empty state instead. Semantic
+views listed for an earlier dependency are discarded, and views cannot be added
+while dependent choices are loading.
diff --git a/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
index 0983e1b4026..267e247c4da 100644
--- a/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
@@ -222,6 +222,42 @@ test('DynamicFieldControl handles a 500-option enum via search (FR-006 sibling)'
   expect(handleChange).toHaveBeenLastCalledWith('mode', 'metric_042');
 });
 
+test.each([true, false])(
+  'DynamicFieldControl closes stale options without losing the value (loading=%s)',
+  async refreshingSchema => {
+    const props = baseProps({
+      label: 'Schema',
+      path: 'schema',
+      schema: {
+        type: 'string',
+        enum: ['shared', 'old_only'],
+        'x-dynamic': true,
+        'x-dependsOn': ['database'],
+      } as ControlProps['schema'],
+      data: 'shared',
+      config: { formData: { database: 'first' } },
+    });
+    const { rerender } = render(<DynamicFieldControl {...props} />);
+    const box = screen.getByRole('combobox');
+    await userEvent.click(box);
+    await waitFor(() => expect(screen.getByText('old_only')).toBeVisible());
+    rerender(
+      <DynamicFieldControl
+        {...props}
+        config={{
+          formData: { database: 'second' },
+          refreshingSchema,
+          staleSchemaOptions: true,
+        }}
+      />,
+    );
+    expect(box).toBeDisabled();
+    await waitFor(() => expect(box).toHaveAttribute('aria-expanded', 'false'));
+    expect(box.closest('.ant-select')).toHaveTextContent('shared');
+    expect(props.handleChange).not.toHaveBeenCalled();
+  },
+);
+
 test('renders options alphabetically for an unsorted backend enum', async () => {
   // The renderers dropped their manual sort because the wrapped Select
   // alphabetises by label; pin that inherited behaviour here so a future
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
index be2c4243a87..8fa6f4d3695 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
@@ -147,9 +147,14 @@ test('ordinary string controls retain the vendor default display', () => {
   expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue('PUBLIC');
 });
 
-test.each([false, true])(
-  'manual fields remain typeable when discovery is pending: %s',
-  refreshingSchema => {
+test.each([
+  [false, false],
+  [true, false],
+  [false, true],
+  [true, true],
+])(
+  'manual fields remain typeable during discovery (refreshing=%s, stale=%s)',
+  (refreshingSchema, staleSchemaOptions) => {
     const schema = {
       type: 'object',
       properties: {
@@ -168,7 +173,11 @@ test.each([false, true])(
         data={{}}
         renderers={renderers}
         cells={cellRegistryEntries}
-        config={{ refreshingSchema, formData: { account: 'synthetic' } }}
+        config={{
+          refreshingSchema,
+          staleSchemaOptions,
+          formData: { account: 'synthetic' },
+        }}
       />,
     );
     expect(screen.getByRole('textbox', { name: 'Database' })).toBeEnabled();
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index ac799b941fc..afa2bbcece5 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -253,7 +253,11 @@ export function areDependenciesSatisfied(
  * TextControl with ``inputProps.suffix`` doesn't reach the underlying Select.
  */
 export function DynamicFieldControl(props: ControlProps) {
-  const { refreshingSchema, formData: cfgData } = props.config ?? {};
+  const {
+    refreshingSchema,
+    staleSchemaOptions,
+    formData: cfgData,
+  } = props.config ?? {};
   const schema = props.schema as Record<string, unknown>;
   const deps = schema?.['x-dependsOn'];
   const refreshing =
@@ -264,6 +268,13 @@ export function DynamicFieldControl(props: ControlProps) {
       (cfgData as Record<string, unknown>) ?? {},
       props.rootSchema,
     );
+  // A field is never stale because of its own value: a self-dependent field
+  // (e.g. MetricFlow's mode picker) must stay selectable to satisfy itself.
+  const fieldName: string = props.path.split('.').pop() ?? props.path;
+  const stale =
+    !!staleSchemaOptions &&
+    Array.isArray(deps) &&
+    (deps as string[]).some(dep => dep !== fieldName);
 
   const enumValues = Array.isArray(schema.enum)
     ? (schema.enum as unknown[])
@@ -303,7 +314,8 @@ export function DynamicFieldControl(props: ControlProps) {
           value={(props.data as string | number | undefined) ?? undefined}
           onChange={value => props.handleChange(props.path, value)}
           options={options}
-          disabled={!props.enabled}
+          // Disabling closes the menu without discarding a still-valid value.
+          disabled={!props.enabled || stale}
           loading={refreshing}
           allowClear
           placeholder={refreshing ? t('Loading...') : placeholder}
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
index 1c85bb0ae78..0b7d4362bc5 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
@@ -17,6 +17,8 @@
  * under the License.
  */
 import {
+  act,
+  fireEvent,
   render,
   screen,
   waitFor,
@@ -29,7 +31,10 @@ import {
   largeRuntimeSchema,
 } from 'src/features/semanticLayers/testFixtures';
 
-import AddSemanticViewModal from './AddSemanticViewModal';
+import AddSemanticViewModal, {
+  reconcileDynamicFieldValues,
+} from './AddSemanticViewModal';
+import { SCHEMA_REFRESH_DEBOUNCE_MS } from '../semanticLayers/jsonFormsHelpers';
 
 jest.mock('@superset-ui/core', () => ({
   ...jest.requireActual('@superset-ui/core'),
@@ -62,6 +67,10 @@ beforeEach(() => {
   mockedPost.mockReset();
 });
 
+afterEach(() => {
+  jest.useRealTimers();
+});
+
 test('loads layers on open and adds selected semantic views', async () => {
   mockedGet.mockResolvedValue({
     json: {
@@ -409,15 +418,28 @@ test('a const array default does not loop updates', async () => {
   }
 });
 
-test('a failed schema refresh surfaces a toast and preserves selections', async () => {
+test('a failed array-only refresh keeps views and Add usable with a bounded retry', async () => {
   let refreshCount = 0;
-  mockLayerWithSchema(dynamicMetricsSchema, {
+  const schemaWithMode = {
+    ...dynamicMetricsSchema,
+    properties: {
+      ...dynamicMetricsSchema.properties,
+      mode: {
+        type: 'string',
+        title: 'Mode',
+        enum: ['full', 'cube'],
+        'x-dynamic': true,
+        'x-dependsOn': ['mode'],
+      },
+    },
+  };
+  mockLayerWithSchema(schemaWithMode, {
     '/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
       if ((payload as { runtime_data?: unknown })?.runtime_data) {
         refreshCount += 1;
         return Promise.reject(new Error('refresh boom'));
       }
-      return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+      return Promise.resolve({ json: { result: schemaWithMode } });
     },
   });
   const props = createProps();
@@ -436,6 +458,18 @@ test('a failed schema refresh surfaces a toast and preserves selections', async
   expect(refreshCount).toBeGreaterThanOrEqual(1);
   // The user's selection is untouched by the failure (spec FR-005).
   expect(selectedTag('m1')).toBeTruthy();
+  await waitFor(
+    () => {
+      expect(
+        screen.getByRole('combobox', { name: 'Semantic views' }),
+      ).toBeEnabled();
+    },
+    { timeout: 10000 },
+  );
+  await selectOption('Semantic views', 'orders');
+  expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+  await pickFromSelect(/metrics/i, 'm2');
+  await waitFor(() => expect(refreshCount).toBe(2), { timeout: 10000 });
 });
 
 test('the save payload carries every selected metric', async () => {
@@ -547,83 +581,721 @@ test('renders and selects within a 500-metric runtime schema', async () => {
   ).toBeUndefined();
 });
 
-test('a persistently failing refresh retries once, not on every edit', async () => {
-  let refreshCount = 0;
-  mockLayerWithSchema(dynamicMetricsSchema, {
+const databaseSchema = (schemas: string[]) => ({
+  type: 'object',
+  required: ['database', 'schema'],
+  properties: {
+    database: {
+      type: 'string',
+      title: 'Database',
+      enum: ['first', 'second', 'third'],
+      'x-enumNames': ['first', 'second', 'third'],
+    },
+    schema: {
+      type: 'string',
+      title: 'Schema',
+      enum: schemas,
+      'x-dynamic': true,
+      'x-dependsOn': ['database'],
+    },
+    note: { type: 'string', title: 'Note' },
+  },
+});
+
+type PendingSchema = {
+  resolve: (schema: Record<string, unknown> | null) => void;
+  reject: (error: Error) => void;
+};
+
+async function chooseDependentOption(name: string, value: string) {
+  fireEvent.mouseDown(await screen.findByRole('combobox', { name }));
+  const option = await waitFor(() => {
+    const item = document.querySelector(
+      `.ant-select-item-option[title="${value}"]`,
+    );
+    if (!item) throw new Error(`Missing option ${value}`);
+    return item;
+  });
+  fireEvent.click(option);
+  // JSON Forms emits onChange after its own 10 ms debounce; the modal's
+  // 500 ms schema-fetch debounce must not have elapsed at this point.
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+}
+
+async function openDependentSchemaForm(
+  makeSchema: typeof databaseSchema = databaseSchema,
+) {
+  jest.useFakeTimers();
+  const pending: PendingSchema[] = [];
+  const initial = makeSchema(['shared', 'old_only']);
+  mockLayerWithSchema(initial, {
     '/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
-      if ((payload as { runtime_data?: unknown })?.runtime_data) {
-        refreshCount += 1;
-        return Promise.reject(new Error('refresh boom'));
+      if (!(payload as { runtime_data?: unknown })?.runtime_data) {
+        return Promise.resolve({ json: { result: initial } });
       }
-      return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+      return new Promise((resolve, reject) => {
+        pending.push({
+          resolve: schema => resolve({ json: { result: schema } }),
+          reject,
+        });
+      });
     },
   });
   const props = createProps();
-  render(<AddSemanticViewModal {...props} />);
-  await selectOption('Semantic layer', 'Snowflake SL');
+  const { rerender } = render(<AddSemanticViewModal {...props} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  // Let JSON Forms emit its mount-time change before selecting a database.
+  // Otherwise that delayed event can reset the dependency snapshot mid-pick.
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await chooseDependentOption('Database', 'first');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(1);
+  await act(async () => {
+    pending[0].resolve(initial);
+  });
+  return { pending, props, rerender };
+}
 
-  await pickFromSelect(/metrics/i, 'm1');
-  await waitFor(() => expect(refreshCount).toBe(1), { timeout: 10000 });
+const viewsRequests = () =>
+  mockedPost.mock.calls
+    .map(([request]) => request)
+    .filter(
+      ({ endpoint }: { endpoint: string }) =>
+        endpoint === '/api/v1/semantic_layer/layer-1/views',
+    );
 
-  // One bounded retry for this dependency state, then no further attempts
-  // however many more edits the user makes during the outage.
-  await pickFromSelect(/dimensions/i, 'd1');
-  await waitFor(() => expect(refreshCount).toBe(2), { timeout: 10000 });
-  await pickFromSelect(/dimensions/i, 'd2');
-  await new Promise(resolve => {
-    setTimeout(resolve, 1200);
+test.each(['shared', 'old_only'])(
+  'dependent options are unavailable through debounce/loading and validate selection %s',
+  async selection => {
+    const { pending } = await openDependentSchemaForm();
+    await chooseDependentOption('Schema', selection);
+    await chooseDependentOption('Database', 'second');
+    const schema = screen.getByRole('combobox', { name: 'Schema' });
+    expect(schema).toBeDisabled();
+    expect(pending).toHaveLength(1);
+    await act(async () => {
+      jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS - 1);
+    });
+    expect(schema).toBeDisabled();
+    expect(pending).toHaveLength(1);
+    await act(async () => {
+      jest.advanceTimersByTime(1);
+    });
+    expect(pending).toHaveLength(2);
+    expect(schema).toBeDisabled();
+    await act(async () => {
+      pending[1].resolve(databaseSchema(['shared', 'new_only']));
+    });
+    await waitFor(() => expect(schema).toBeEnabled());
+    if (selection === 'shared') {
+      expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+    } else {
+      expect(selectedTag('old_only')).toBeNull();
+    }
+    fireEvent.mouseDown(schema);
+    expect(await screen.findByText('new_only')).toBeInTheDocument();
+    expect(
+      document.querySelector('.ant-select-item-option[title="old_only"]'),
+    ).toBeNull();
+  },
+);
+
+test('dependent options ignore an older response while the latest refresh is pending', async () => {
+  const { pending } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await chooseDependentOption('Database', 'third');
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  await act(async () => {
+    pending[1].resolve(databaseSchema(['wrong_database']));
+  });
+  expect(schema).toBeDisabled();
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+  await act(async () => {
+    pending[2].resolve(databaseSchema(['shared', 'third_only']));
+  });
+  await waitFor(() => expect(schema).toBeEnabled());
+  fireEvent.mouseDown(schema);
+  expect(await screen.findByText('third_only')).toBeInTheDocument();
+  expect(screen.queryByText('wrong_database')).not.toBeInTheDocument();
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+});
+
+test('dependent options stay unavailable after an error and recover on the bounded retry', async () => {
+  const { pending, props } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await act(async () => {
+    pending[1].reject(new Error('offline'));
+  });
+  expect(props.addDangerToast).toHaveBeenCalledWith(
+    'An error occurred while refreshing the runtime schema',
+  );
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  expect(schema).toBeDisabled();
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+  fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+    target: { value: 'retry' },
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+  expect(schema).toBeDisabled();
+  await act(async () => {
+    pending[2].resolve(databaseSchema(['shared', 'recovered']));
+  });
+  await waitFor(() => expect(schema).toBeEnabled());
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+  fireEvent.mouseDown(schema);
+  expect(await screen.findByText('recovered')).toBeInTheDocument();
+  expect(props.addDangerToast).toHaveBeenCalledTimes(1);
+});
+
+test('failed scalar narrowing keeps Add and views unavailable until a matching response', async () => {
+  const { pending } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await chooseDependentOption('Semantic views', 'orders');
+  expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await act(async () => {
+    pending[1].reject(new Error('offline'));
   });
-  expect(refreshCount).toBe(2);
-  // And the user was told once, not once per attempt.
   expect(
-    props.addDangerToast.mock.calls.filter(([msg]) =>
-      String(msg).includes('refreshing the runtime schema'),
+    screen.getByRole('combobox', { name: 'Semantic views' }),
+  ).toBeDisabled();
+  const viewsBefore = viewsRequests().length;
+  fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+    target: { value: 'retry' },
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(viewsRequests()).toHaveLength(viewsBefore);
+  expect(
+    screen.getByRole('combobox', { name: 'Semantic views' }),
+  ).toBeDisabled();
+  expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+  await act(async () => {
+    pending[2].resolve(databaseSchema(['shared', 'new_only']));
+  });
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('manual text entered while options are stale survives an empty-enum refresh', async () => {
+  const { pending } = await openDependentSchemaForm(() => ({
+    ...databaseSchema([]),
+    required: ['database'],
+  }));
+  await chooseDependentOption('Database', 'second');
+  const schema = screen.getByRole('textbox', { name: 'Schema' });
+  expect(schema).toBeEnabled();
+  fireEvent.change(schema, { target: { value: 'manual_schema' } });
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(2);
+  expect(schema).toHaveValue('manual_schema');
+  await act(async () => {
+    pending[1].resolve({ ...databaseSchema([]), required: ['database'] });
+  });
+  expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue(
+    'manual_schema',
+  );
+});
+
+test('reconciliation retains manual text for empty enums and clears old selections', () => {
+  const emptyEnum = databaseSchema([]);
+  expect(
+    reconcileDynamicFieldValues(
+      { database: 'second', schema: 'manual_schema' },
+      emptyEnum,
+      emptyEnum,
     ),
-  ).toHaveLength(1);
+  ).toEqual({ database: 'second', schema: 'manual_schema' });
+  expect(
+    reconcileDynamicFieldValues(
+      { database: 'second', schema: 'old_only' },
+      databaseSchema(['old_only']),
+      emptyEnum,
+    ),
+  ).toEqual({ database: 'second' });
+  expect(
+    reconcileDynamicFieldValues(
+      { database: 'third', schema: 'manual_schema' },
+      emptyEnum,
+      databaseSchema(['third_only']),
+    ),
+  ).toEqual({ database: 'third' });
 });
 
-test('a superseded refresh response cannot toast or clobber a newer one', async () => {
-  // Exercises schemaRefreshGenRef: when two refreshes overlap and the older
-  // one settles last, its failure must be ignored — no error toast, and no
-  // rollback of the dependency snapshot the newer request committed.
-  let call = 0;
-  const resolvers: Array<() => void> = [];
-  mockLayerWithSchema(dynamicMetricsSchema, {
+test('a refresh response without a schema reports failure and permits one retry', async () => {
+  const { pending, props } = await openDependentSchemaForm();
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await act(async () => {
+    pending[1].resolve(null);
+  });
+  expect(props.addDangerToast).toHaveBeenCalledWith(
+    'An error occurred while refreshing the runtime schema',
+  );
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+  fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+    target: { value: 'retry' },
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+});
+
+test.each([{ values: ['new_only'] }, { values: [] }])(
+  'dependent selection is cleared against matching enum $values',
+  async ({ values }) => {
+    const consoleError = jest.spyOn(console, 'error');
+    const { pending } = await openDependentSchemaForm();
+    await chooseDependentOption('Schema', 'old_only');
+    await chooseDependentOption('Database', 'second');
+    await act(async () => {
+      jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+    });
+    await act(async () => {
+      pending[1].resolve(databaseSchema(values));
+    });
+    expect(selectedTag('old_only')).toBeNull();
+    if (values.length === 0)
+      expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue('');
+    const hookOrderChanged = consoleError.mock.calls.some(args =>
+      String(args[0]).includes('change in the order of Hooks'),
+    );
+    consoleError.mockRestore();
+    expect(hookOrderChanged).toBe(false);
+  },
+);
+
+test('dependent options are unavailable when a dependency is cleared', async () => {
+  const { pending } = await openDependentSchemaForm();
+  const database = screen.getByRole('combobox', { name: 'Database' });
+  const clear = database
+    .closest('.ant-select')
+    ?.querySelector('.ant-select-clear');
+  expect(clear).toBeTruthy();
+  if (clear) fireEvent.click(clear);
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  expect(database.closest('.ant-select')).not.toHaveTextContent('first');
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  expect(schema).toBeDisabled();
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(1);
+  expect(schema).toBeDisabled();
+  await chooseDependentOption('Database', 'third');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await act(async () => {
+    pending[1].resolve(databaseSchema(['third_only']));
+  });
+  await waitFor(() => expect(schema).toBeEnabled());
+});
+
+const optionalDatabaseSchema = (schemas: string[]) => ({
+  ...databaseSchema(schemas),
+  required: ['schema'],
+});
+
+test('clearing an optional dependency on a valid form refreshes options and fetches views', async () => {
+  const { pending } = await openDependentSchemaForm(optionalDatabaseSchema);
+  await chooseDependentOption('Schema', 'shared');
+  const database = screen.getByRole('combobox', { name: 'Database' });
+  const clear = database
+    .closest('.ant-select')
+    ?.querySelector('.ant-select-clear');
+  expect(clear).toBeTruthy();
+  if (clear) fireEvent.click(clear);
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  expect(schema).toBeDisabled();
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  // The optional dependency is empty but the form is valid, so the options
+  // must be refreshed for that state rather than stay unavailable forever.
+  expect(pending).toHaveLength(2);
+  await act(async () => {
+    pending[1].resolve(optionalDatabaseSchema(['shared']));
+  });
+  await waitFor(() => expect(schema).toBeEnabled());
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await waitFor(() =>
+    expect(
+      viewsRequests().some(
+        ({ jsonPayload }: { jsonPayload: { runtime_data?: unknown } }) =>
+          (jsonPayload.runtime_data as Record<string, unknown> | undefined)
+            ?.database == null,
+      ),
+    ).toBe(true),
+  );
+});
+
+test('a dependency satisfied by its schema default refreshes dependent options', async () => {
+  jest.useFakeTimers();
+  const pending: PendingSchema[] = [];
+  const base = databaseSchema(['shared']);
+  const initial = {
+    ...base,
+    properties: {
+      ...base.properties,
+      database: { ...base.properties.database, default: 'first' },
+    },
+  };
+  mockLayerWithSchema(initial, {
     '/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
       if (!(payload as { runtime_data?: unknown })?.runtime_data) {
-        return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+        return Promise.resolve({ json: { result: initial } });
       }
-      call += 1;
-      if (call === 1) {
-        // First (soon-to-be superseded) refresh: fails, but only after the
-        // second one has already been issued.
-        return new Promise((_resolve, reject) => {
-          resolvers.push(() => reject(new Error('stale refresh boom')));
+      return new Promise((resolve, reject) => {
+        pending.push({
+          resolve: schema => resolve({ json: { result: schema } }),
+          reject,
         });
+      });
+    },
+  });
+  render(<AddSemanticViewModal {...createProps()} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  await chooseDependentOption('Schema', 'shared');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(1);
+  // The default is not sent; the provider applies it for the absent key.
+  const refreshPayloads = mockedPost.mock.calls
+    .map(([request]) => request)
+    .filter(
+      ({ endpoint, jsonPayload }) =>
+        endpoint === '/api/v1/semantic_layer/layer-1/schema/runtime' &&
+        jsonPayload?.runtime_data,
+    );
+  expect(refreshPayloads.at(-1).jsonPayload.runtime_data).toEqual({
+    schema: 'shared',
+  });
+  await act(async () => {
+    pending[0].resolve(initial);
+  });
+  await waitFor(() =>
+    expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled(),
+  );
+});
+
+test('an empty optional dependency does not block a required dependent field', async () => {
+  jest.useFakeTimers();
+  const pending: PendingSchema[] = [];
+  const initial = optionalDatabaseSchema(['shared']);
+  mockLayerWithSchema(initial, {
+    '/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
+      if (!(payload as { runtime_data?: unknown })?.runtime_data) {
+        return Promise.resolve({ json: { result: initial } });
       }
-      return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+      return new Promise((resolve, reject) => {
+        pending.push({
+          resolve: schema => resolve({ json: { result: schema } }),
+          reject,
+        });
+      });
     },
   });
-  const props = createProps();
-  render(<AddSemanticViewModal {...props} />);
-  await selectOption('Semantic layer', 'Snowflake SL');
+  render(<AddSemanticViewModal {...createProps()} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  // The only error is the required dependent field; the optional dependency
+  // is empty, so its options must still load instead of staying disabled.
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(1);
+  await act(async () => {
+    pending[0].resolve(optionalDatabaseSchema(['no_database']));
+  });
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  await waitFor(() => expect(schema).toBeEnabled());
+  fireEvent.mouseDown(schema);
+  expect(await screen.findByText('no_database')).toBeInTheDocument();
+});
 
-  await pickFromSelect(/metrics/i, 'm1');
-  await waitFor(() => expect(call).toBe(1), { timeout: 10000 });
-  // Second selection supersedes the in-flight refresh.
-  await pickFromSelect(/metrics/i, 'm2');
-  await waitFor(() => expect(call).toBe(2), { timeout: 10000 });
-  // Now let the stale first request fail, last.
-  resolvers.forEach(fn => fn());
-  await new Promise(resolve => {
-    setTimeout(resolve, 1000);
+test('a refresh that drops the dependent value keeps the new options available', async () => {
+  const { pending } = await openDependentSchemaForm(optionalDatabaseSchema);
+  await chooseDependentOption('Schema', 'old_only');
+  const database = screen.getByRole('combobox', { name: 'Database' });
+  const clear = database
+    .closest('.ant-select')
+    ?.querySelector('.ant-select-clear');
+  if (clear) fireEvent.click(clear);
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(2);
+  await act(async () => {
+    pending[1].resolve(optionalDatabaseSchema(['no_database']));
+  });
+  // Reconcile drops old_only, leaving the required dependent field empty;
+  // that error must not hide the options the refresh just loaded.
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  await waitFor(() => expect(schema).toBeEnabled());
+  expect(selectedTag('old_only')).toBeNull();
+});
+
+test('a self-dependent required field stays selectable when the form opens', async () => {
+  jest.useFakeTimers();
+  // MetricFlow's mode picker depends on its own value and is required.
+  const modeSchema = {
+    type: 'object',
+    required: ['mode'],
+    properties: {
+      mode: {
+        type: 'string',
+        title: 'Mode',
+        enum: ['full', 'cube'],
+        'x-enumNames': ['Full semantic layer', 'Cube'],
+        'x-dynamic': true,
+        'x-dependsOn': ['mode'],
+      },
+    },
+  };
+  mockLayerWithSchema(modeSchema);
+  render(<AddSemanticViewModal {...createProps()} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  await act(async () => {
+    jest.advanceTimersByTime(10);
   });
+  const mode = await screen.findByRole('combobox', { name: 'Mode' });
+  expect(mode).toBeEnabled();
+  await chooseDependentOption('Mode', 'Full semantic layer');
+  expect(mode.closest('.ant-select')).toHaveTextContent('Full semantic layer');
+  expect(mode).toBeEnabled();
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(mode).toBeEnabled();
+});
 
+test('a dependency change cancels views queued for the previous dependency state', async () => {
+  const { pending } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  // Views for {database: first, schema: shared} are queued but not yet sent.
+  await chooseDependentOption('Database', 'second');
+  const sentBefore = viewsRequests().length;
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  const staleViewRequests = viewsRequests()
+    .slice(sentBefore)
+    .filter(
+      ({ jsonPayload }: { jsonPayload: { runtime_data?: unknown } }) =>
+        (jsonPayload.runtime_data as Record<string, unknown>)?.database ===
+        'first',
+    );
+  expect(staleViewRequests).toHaveLength(0);
+  expect(pending).toHaveLength(2);
+});
+
+test('a dependency change discards an in-flight views response', async () => {
+  const { pending } = await openDependentSchemaForm();
+  const originalPost = mockedPost.getMockImplementation();
+  let finishViews: (() => void) | undefined;
+  let viewsCall = 0;
+  mockedPost.mockImplementation((request: { endpoint: string }) => {
+    if (request.endpoint === '/api/v1/semantic_layer/layer-1/views') {
+      viewsCall += 1;
+      if (viewsCall === 1) {
+        return Promise.resolve({
+          json: { result: [{ name: 'old_view', already_added: false }] },
+        });
+      }
+      return new Promise(resolve => {
+        finishViews = () =>
+          resolve({
+            json: { result: [{ name: 'old_view', already_added: false }] },
+          });
+      });
+    }
+    return originalPost?.(request);
+  });
+  await chooseDependentOption('Schema', 'shared');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await chooseDependentOption('Semantic views', 'old_view');
+  expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+  fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+    target: { value: 'new request' },
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(10);
+  });
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(viewsCall).toBe(2);
+  expect(finishViews).toBeDefined();
+  await chooseDependentOption('Database', 'second');
+  expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+  await act(async () => {
+    finishViews?.();
+  });
+  expect(selectedTag('old_view')).toBeNull();
+  expect(
+    screen.getByRole('combobox', { name: 'Semantic views' }),
+  ).toBeDisabled();
+  expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(2);
+  expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+});
+
+test('closing and reopening clears stale dependent-option state', async () => {
+  const { props, rerender } = await openDependentSchemaForm();
+  await chooseDependentOption('Database', 'second');
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+  rerender(<AddSemanticViewModal {...props} show={false} />);
+  rerender(<AddSemanticViewModal {...props} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('switching layers clears stale dependent-option state', async () => {
+  jest.useFakeTimers();
+  const schema = databaseSchema(['shared']);
+  mockLayerWithSchema(schema, {
+    '/api/v1/semantic_layer/layer-2/schema/runtime': () =>
+      Promise.resolve({ json: { result: schema } }),
+  });
+  mockedGet.mockResolvedValue({
+    json: {
+      result: [
+        { uuid: 'layer-1', name: 'Snowflake SL' },
+        { uuid: 'layer-2', name: 'Second SL' },
+      ],
+    },
+  });
+  render(<AddSemanticViewModal {...createProps()} />);
+  await chooseDependentOption('Semantic layer', 'Snowflake SL');
+  await chooseDependentOption('Database', 'first');
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+  await chooseDependentOption('Semantic layer', 'Second SL');
+  expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('a persistently failing refresh retries once, not on every edit', async () => {
+  const { pending, props } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(2);
+  await act(async () => {
+    pending[1].reject(new Error('refresh boom'));
+  });
+
+  const note = screen.getByRole('textbox', { name: 'Note' });
+  fireEvent.change(note, { target: { value: 'retry' } });
+  await act(async () => {
+    jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+  await act(async () => {
+    pending[2].reject(new Error('refresh boom'));
+  });
+
+  // More edits in the same dependency state must not schedule a third attempt
+  // or fetch views for the stale options.
+  const viewsBefore = viewsRequests().length;
+  fireEvent.change(note, { target: { value: 'another edit' } });
+  await act(async () => {
+    jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+  expect(viewsRequests()).toHaveLength(viewsBefore);
   expect(
     props.addDangerToast.mock.calls.filter(([msg]) =>
       String(msg).includes('refreshing the runtime schema'),
     ),
-  ).toHaveLength(0);
-  expect(selectedTag('m1')).toBeTruthy();
-  expect(selectedTag('m2')).toBeTruthy();
+  ).toHaveLength(1);
+});
+
+test('a superseded refresh failure cannot toast or clobber a newer response', async () => {
+  const { pending, props } = await openDependentSchemaForm();
+  await chooseDependentOption('Schema', 'shared');
+  await chooseDependentOption('Database', 'second');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  await chooseDependentOption('Database', 'third');
+  await act(async () => {
+    jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+  });
+  expect(pending).toHaveLength(3);
+  await act(async () => {
+    pending[2].resolve(databaseSchema(['shared', 'third_only']));
+  });
+  const schema = screen.getByRole('combobox', { name: 'Schema' });
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+  await act(async () => {
+    pending[1].reject(new Error('stale refresh boom'));
+  });
+  expect(props.addDangerToast).not.toHaveBeenCalled();
+  expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+  fireEvent.mouseDown(schema);
+  expect(await screen.findByText('third_only')).toBeInTheDocument();
 });
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index 63e5cdd8d88..a22f2a88010 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -54,6 +54,31 @@ interface AvailableView {
   already_added: boolean;
 }
 
+/** Keep manual text when discovery has no choices; drop invalid enum selections. */
+export function reconcileDynamicFieldValues(
+  previous: Record<string, unknown>,
+  previousSchema: JsonSchema | null,
+  rawSchema: JsonSchema,
+): Record<string, unknown> {
+  const invalidFields = Object.entries(rawSchema.properties ?? {})
+    .filter(
+      ([key, field]) =>
+        (field as Record<string, unknown>)['x-dynamic'] === true &&
+        Array.isArray(field.enum) &&
+        (field.enum.length > 0 ||
+          (previousSchema?.properties?.[key]?.enum?.length ?? 0) > 0) &&
+        key in previous &&
+        !field.enum.includes(previous[key]),
+    )
+    .map(([key]) => key);
+  if (invalidFields.length === 0) return previous;
+  const next = { ...previous };
+  invalidFields.forEach(key => {
+    delete next[key];
+  });
+  return next;
+}
+
 const ModalContent = styled.div`
   padding: ${({ theme }) => theme.sizeUnit * 4}px;
 `;
@@ -135,12 +160,16 @@ export default function AddSemanticViewModal({
 
   // --- Runtime config ---
   const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
+  // Read by the change handler without re-creating it on every schema load.
+  const runtimeSchemaRef = useRef<JsonSchema | null>(null);
+  runtimeSchemaRef.current = runtimeSchema;
   const [runtimeUiSchema, setRuntimeUiSchema] = useState<
     UISchemaElement | undefined
   >();
   const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
   const [loadingRuntime, setLoadingRuntime] = useState(false);
   const [refreshingSchema, setRefreshingSchema] = useState(false);
+  const [staleSchemaOptions, setStaleSchemaOptions] = useState(false);
   const errorsRef = useRef<ErrorObject[]>([]);
   const dynamicDepsRef = useRef<Record<string, string[]>>({});
   const lastDepSnapshotRef = useRef('');
@@ -156,6 +185,9 @@ export default function AddSemanticViewModal({
   // --- Misc ---
   const [saving, setSaving] = useState(false);
   const fetchGenRef = useRef(0);
+  // Bumped whenever dependent options go stale, so views queued or in
+  // flight for a previous dependency state never land.
+  const viewsGenRef = useRef(0);
 
   // =========================================================================
   // Fetch helpers
@@ -182,6 +214,9 @@ export default function AddSemanticViewModal({
 
   const fetchViews = useCallback(
     async (uuid: string, rData: Record<string, unknown>, gen: number) => {
+      const viewsGen: number = viewsGenRef.current;
+      const isCurrent = (): boolean =>
+        gen === fetchGenRef.current && viewsGen === viewsGenRef.current;
       setLoadingViews(true);
       setAvailableViews([]);
       setSelectedViewNames([]);
@@ -190,13 +225,13 @@ export default function AddSemanticViewModal({
           endpoint: `/api/v1/semantic_layer/${uuid}/views`,
           jsonPayload: { runtime_data: rData },
         });
-        if (gen !== fetchGenRef.current) return;
+        if (!isCurrent()) return;
         setAvailableViews(json.result ?? []);
       } catch {
-        if (gen !== fetchGenRef.current) return;
+        if (!isCurrent()) return;
         addDangerToast(t('An error occurred while fetching available views'));
       } finally {
-        if (gen === fetchGenRef.current) setLoadingViews(false);
+        if (isCurrent()) setLoadingViews(false);
       }
     },
     [addDangerToast],
@@ -213,6 +248,12 @@ export default function AddSemanticViewModal({
   const retriedDepSnapshotRef = useRef('');
   const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
     dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+    const previousSchema = runtimeSchemaRef.current;
+    // Reconcile against the matching response, not an intermediate empty
+    // option list. Retain selections that are valid in both dependency states.
+    setRuntimeData(previous =>
+      reconcileDynamicFieldValues(previous, previousSchema, rawSchema),
+    );
     const schema = sanitizeSchema(rawSchema);
     const uiSchema = buildUiSchema(schema);
     // Dedupe key = key-order-canonical schema + the derived ui schema.
@@ -246,6 +287,16 @@ export default function AddSemanticViewModal({
     [fetchViews],
   );
 
+  // Drop views queued or in flight for the previous dependency state.
+  const invalidateViews = useCallback(() => {
+    viewsGenRef.current += 1;
+    if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
+    setLoadingViews(false);
+    setAvailableViews([]);
+    setSelectedViewNames([]);
+    lastViewsKeyRef.current = '';
+  }, []);
+
   // =========================================================================
   // Layer change — fetch runtime schema, clear downstream state
   // =========================================================================
@@ -261,6 +312,8 @@ export default function AddSemanticViewModal({
       setRuntimeSchema(null);
       setRuntimeUiSchema(undefined);
       setRuntimeData({});
+      setRefreshingSchema(false);
+      setStaleSchemaOptions(false);
       errorsRef.current = [];
       dynamicDepsRef.current = {};
       lastDepSnapshotRef.current = '';
@@ -325,87 +378,131 @@ export default function AddSemanticViewModal({
       // Dynamic deps changed → refresh schema (e.g. database → schema)
       const dynamicDeps = dynamicDepsRef.current;
       if (Object.keys(dynamicDeps).length > 0) {
+        // Count schema defaults as DynamicFieldControl does, so a dependency
+        // satisfied only by its default still refreshes dependent options.
+        const schema: JsonSchema | undefined =
+          runtimeSchemaRef.current ?? undefined;
         const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
-          areDependenciesSatisfied(deps, data),
+          areDependenciesSatisfied(deps, data, schema),
         );
-        if (hasSatisfiedDeps) {
-          const snapshot = serializeDependencyValues(dynamicDeps, data);
-          if (snapshot !== lastDepSnapshotRef.current) {
-            lastDepSnapshotRef.current = snapshot;
-            // Config is changing — clear views
-            setAvailableViews([]);
-            setSelectedViewNames([]);
-            lastViewsKeyRef.current = '';
-            if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
-            const uuid = selectedLayerUuid;
-            schemaRefreshGenRef.current += 1;
-            const refreshGen = schemaRefreshGenRef.current;
-            schemaTimerRef.current = setTimeout(async () => {
-              setRefreshingSchema(true);
-              try {
-                const { json } = await SupersetClient.post({
-                  endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
-                  jsonPayload: { runtime_data: data },
-                });
-                if (
-                  gen !== fetchGenRef.current ||
-                  refreshGen !== schemaRefreshGenRef.current
-                )
-                  return;
-                // Reset before applying: the request itself succeeded, so a
-                // throw inside applyRuntimeSchema must not be reported (or
-                // retried) as a transient network failure.
-                refreshErrorToastShownRef.current = false;
-                retriedDepSnapshotRef.current = '';
-                if (json.result) applyRuntimeSchema(json.result);
-              } catch (error) {
-                if (
-                  gen !== fetchGenRef.current ||
-                  refreshGen !== schemaRefreshGenRef.current
-                )
-                  return;
-                logging.error('Runtime schema refresh failed', error);
-                // Allow exactly one retry per dependency state: clearing the
-                // committed snapshot lets the next change event re-attempt
-                // the refresh, but only if this state hasn't been retried
-                // already, so a persistent outage doesn't re-fire on every
-                // edit. Only clear a snapshot this attempt still owns — a
-                // newer change may have committed its own since.
-                if (
-                  lastDepSnapshotRef.current === snapshot &&
-                  retriedDepSnapshotRef.current !== snapshot
-                ) {
-                  retriedDepSnapshotRef.current = snapshot;
-                  lastDepSnapshotRef.current = '';
-                }
-                // The form (and the user's selections) stay intact; only the
-                // dynamic narrowing is stale, so surface it without blocking
-                // — once per outage, not once per debounced attempt.
-                if (!refreshErrorToastShownRef.current) {
-                  refreshErrorToastShownRef.current = true;
-                  addDangerToast(
-                    t('An error occurred while refreshing the runtime schema'),
-                  );
-                }
-              } finally {
-                if (
-                  gen === fetchGenRef.current &&
-                  refreshGen === schemaRefreshGenRef.current
-                )
-                  setRefreshingSchema(false);
+        // Wait for input only while a missing dependency is required: a
+        // refresh is the only way to clear stale options, so an empty
+        // optional dependency must still refresh for that state.
+        const required: string[] = Array.isArray(schema?.required)
+          ? schema.required
+          : [];
+        const awaitsRequiredDependency = Object.values(dynamicDeps).some(deps =>
+          deps.some(
+            dep =>
+              required.includes(dep) &&
+              !areDependenciesSatisfied([dep], data, schema),
+          ),
+        );
+        if (!hasSatisfiedDeps && awaitsRequiredDependency) {
+          // Not a committed state: the next valid change must refresh.
+          lastDepSnapshotRef.current = '';
+          schemaRefreshGenRef.current += 1;
+          if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+          setRefreshingSchema(false);
+          setStaleSchemaOptions(true);
+          invalidateViews();
+          return;
+        }
+        const snapshot = serializeDependencyValues(dynamicDeps, data);
+        if (snapshot !== lastDepSnapshotRef.current) {
+          lastDepSnapshotRef.current = snapshot;
+          setRefreshingSchema(true);
+          setStaleSchemaOptions(true);
+          // Config is changing — clear views
+          invalidateViews();
+          if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+          const uuid = selectedLayerUuid;
+          schemaRefreshGenRef.current += 1;
+          const refreshGen = schemaRefreshGenRef.current;
+          schemaTimerRef.current = setTimeout(async () => {
+            try {
+              const { json } = await SupersetClient.post({
+                endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+                jsonPayload: { runtime_data: data },
+              });
+              if (
+                gen !== fetchGenRef.current ||
+                refreshGen !== schemaRefreshGenRef.current
+              )
+                return;
+              if (!json.result)
+                throw new Error('Runtime schema refresh returned no schema');
+              // The request succeeded, so end the current outage; a later
+              // failure can toast and retry again.
+              refreshErrorToastShownRef.current = false;
+              retriedDepSnapshotRef.current = '';
+              applyRuntimeSchema(json.result);
+              setStaleSchemaOptions(false);
+            } catch (error) {
+              if (
+                gen !== fetchGenRef.current ||
+                refreshGen !== schemaRefreshGenRef.current
+              )
+                return;
+              logging.error('Runtime schema refresh failed', error);
+              // Allow exactly one retry per dependency state: clearing the
+              // committed snapshot lets the next change event re-attempt
+              // the refresh, but only if this state hasn't been retried
+              // already, so a persistent outage doesn't re-fire on every
+              // edit. Only clear a snapshot this attempt still owns — a
+              // newer change may have committed its own since.
+              if (
+                lastDepSnapshotRef.current === snapshot &&
+                retriedDepSnapshotRef.current !== snapshot
+              ) {
+                retriedDepSnapshotRef.current = snapshot;
+                lastDepSnapshotRef.current = '';
               }
-            }, SCHEMA_REFRESH_DEBOUNCE_MS);
-            return;
-          }
+              // Only scalar choices can remain unsafe after a failed refresh;
+              // array choices are not reconciled and should not block views.
+              const hasStaleScalarChoices = Object.entries(
+                schema?.properties ?? {},
+              ).some(
+                ([key, field]) =>
+                  (dynamicDeps[key] ?? []).some(dep => dep !== key) &&
+                  field.type !== 'array' &&
+                  field.type !== 'object' &&
+                  Array.isArray(field.enum) &&
+                  field.enum.length > 0,
+              );
+              setStaleSchemaOptions(hasStaleScalarChoices);
+              // Retain selections and toast once per outage.
+              if (!refreshErrorToastShownRef.current) {
+                refreshErrorToastShownRef.current = true;
+                addDangerToast(
+                  t('An error occurred while refreshing the runtime schema'),
+                );
+              }
+            } finally {
+              if (
+                gen === fetchGenRef.current &&
+                refreshGen === schemaRefreshGenRef.current
+              )
+                setRefreshingSchema(false);
+            }
+          }, SCHEMA_REFRESH_DEBOUNCE_MS);
+          return;
         }
       }
 
       // No schema refresh needed — fetch views if form is valid
-      if (errorsRef.current.length === 0) {
+      if (!staleSchemaOptions && errorsRef.current.length === 0) {
         scheduleFetchViews(selectedLayerUuid, data);
       }
     },
-    [selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews, addDangerToast],
+    [
+      selectedLayerUuid,
+      applyRuntimeSchema,
+      scheduleFetchViews,
+      invalidateViews,
+      addDangerToast,
+      staleSchemaOptions,
+    ],
   );
 
   // After a schema refresh settles, JSON Forms re-validates and fires
@@ -413,7 +510,12 @@ export default function AddSemanticViewModal({
   // (in case onChange doesn't fire), try once refreshingSchema flips false.
   const prevRefreshingRef = useRef(false);
   useEffect(() => {
-    if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
+    if (
+      prevRefreshingRef.current &&
+      !refreshingSchema &&
+      !staleSchemaOptions &&
+      selectedLayerUuid
+    ) {
       const timer = setTimeout(() => {
         if (errorsRef.current.length === 0) {
           scheduleFetchViews(selectedLayerUuid, runtimeData);
@@ -424,7 +526,13 @@ export default function AddSemanticViewModal({
     }
     prevRefreshingRef.current = refreshingSchema;
     return undefined;
-  }, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
+  }, [
+    refreshingSchema,
+    staleSchemaOptions,
+    selectedLayerUuid,
+    runtimeData,
+    scheduleFetchViews,
+  ]);
 
   // =========================================================================
   // Modal open / close
@@ -445,6 +553,7 @@ export default function AddSemanticViewModal({
       setRuntimeData({});
       setLoadingRuntime(false);
       setRefreshingSchema(false);
+      setStaleSchemaOptions(false);
       errorsRef.current = [];
       dynamicDepsRef.current = {};
       lastDepSnapshotRef.current = '';
@@ -536,8 +645,8 @@ export default function AddSemanticViewModal({
   // every control, so an inline literal would re-render all of them on each
   // modal render.
   const jsonFormsConfig = useMemo(
-    () => ({ refreshingSchema, formData: runtimeData }),
-    [refreshingSchema, runtimeData],
+    () => ({ refreshingSchema, staleSchemaOptions, formData: runtimeData }),
+    [refreshingSchema, staleSchemaOptions, runtimeData],
   );
 
   // Sorted copy — sorting in place would mutate React state during render.
@@ -589,7 +698,9 @@ export default function AddSemanticViewModal({
       title={t('Add Semantic View')}
       icon={<Icons.PlusOutlined />}
       width={MODAL_STANDARD_WIDTH}
-      saveDisabled={newViewCount === 0 || saving}
+      saveDisabled={
+        newViewCount === 0 || saving || staleSchemaOptions || refreshingSchema
+      }
       saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
       saveLoading={saving}
     >