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

baoyuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new dd641c9b0 fix: preserve plugins with empty config using targeted 
approach (#3277)
dd641c9b0 is described below

commit dd641c9b09defc3088cf171fa438d50e44e1f6d3
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Mon Apr 13 19:23:35 2026 +0530

    fix: preserve plugins with empty config using targeted approach (#3277)
---
 e2e/pom/stream_routes.ts                           |   2 +-
 e2e/server/apisix_conf.yml                         |   5 +
 e2e/tests/plugin_metadata.crud-all-fields.spec.ts  |   3 +-
 e2e/tests/protos.crud-all-fields.spec.ts           |   5 +-
 e2e/tests/protos.crud-required-fields.spec.ts      |   4 +-
 e2e/tests/routes.crud-all-fields.spec.ts           |  11 +-
 e2e/tests/routes.empty-plugin-config.spec.ts       | 257 +++++++++++++++++++++
 e2e/tests/services.crud-required-fields.spec.ts    |   3 +
 e2e/tests/services.stream_routes.crud.spec.ts      |   1 -
 e2e/tests/stream_routes.crud-all-fields.spec.ts    |   3 +
 .../stream_routes.crud-required-fields.spec.ts     |   3 +
 e2e/tests/upstreams.empty-discovery-args.spec.ts   | 117 ++++++++++
 e2e/utils/ui/index.ts                              |  29 ++-
 e2e/utils/ui/upstreams.ts                          |  10 +-
 .../FormItemPlugins/PluginEditorDrawer.tsx         |   1 +
 src/components/form-slice/FormPartRoute/util.ts    |   2 +
 .../{FormPartRoute => FormPartStreamRoute}/util.ts |  37 +--
 .../FormPartUpstream/FormSectionDiscovery.tsx      |   1 +
 src/components/form-slice/FormPartUpstream/util.ts |  50 ++++
 src/components/form/Editor.tsx                     |  17 +-
 src/components/form/JsonInput.tsx                  |   1 +
 src/config/req.ts                                  |   5 +-
 src/routes/routes/detail.$id.tsx                   |  22 +-
 src/routes/services/add.tsx                        |   5 +-
 src/routes/services/detail.$id/index.tsx           |   3 +-
 src/routes/stream_routes/add.tsx                   |   4 +-
 src/routes/upstreams/add.tsx                       |   9 +-
 src/routes/upstreams/detail.$id.tsx                |  36 ++-
 src/utils/producer.ts                              |  37 ++-
 29 files changed, 614 insertions(+), 69 deletions(-)

diff --git a/e2e/pom/stream_routes.ts b/e2e/pom/stream_routes.ts
index ce78e11e7..fa2077f61 100644
--- a/e2e/pom/stream_routes.ts
+++ b/e2e/pom/stream_routes.ts
@@ -47,7 +47,7 @@ const assert = {
     const title = page.getByRole('heading', {
       name: 'Stream Route Detail',
     });
-    await expect(title).toBeVisible({ timeout: 20000 });
+    await expect(title).toBeVisible({ timeout: 30000 });
   },
 };
 
diff --git a/e2e/server/apisix_conf.yml b/e2e/server/apisix_conf.yml
index 94532cf57..7d75f9436 100644
--- a/e2e/server/apisix_conf.yml
+++ b/e2e/server/apisix_conf.yml
@@ -40,3 +40,8 @@ deployment:
       - "http://etcd:2379"; # multiple etcd address
     prefix: "/apisix" # apisix configurations prefix
     timeout: 30 # 30 seconds
+
+discovery:
+  nacos:
+    host:
+      - "http://nacos:8848";
diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts 
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 907c530c5..4774f6720 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -56,7 +56,7 @@ const getMonacoEditorValue = async (editPluginDialog: 
Locator) => {
 
   if (!editorValue || editorValue.trim() === '{') {
     const allText = await editPluginDialog.textContent();
-    console.log('DEBUG: editorValue fallback failed, dialog text:', allText);
+    void allText; // fallback failed, editor value unavailable
   }
   return editorValue;
 };
@@ -64,7 +64,6 @@ const getMonacoEditorValue = async (editPluginDialog: 
Locator) => {
 // Helper function to close edit dialog
 const closeEditDialog = async (editPluginDialog: Locator) => {
   const buttons = await editPluginDialog.locator('button').allTextContents();
-  console.log('DEBUG: Edit Plugin dialog buttons:', buttons);
   let closed = false;
   for (const [i, name] of buttons.entries()) {
     if (name.trim().toLowerCase() === 'cancel') {
diff --git a/e2e/tests/protos.crud-all-fields.spec.ts 
b/e2e/tests/protos.crud-all-fields.spec.ts
index 3e14854dd..1c7805fee 100644
--- a/e2e/tests/protos.crud-all-fields.spec.ts
+++ b/e2e/tests/protos.crud-all-fields.spec.ts
@@ -74,8 +74,9 @@ test.describe('CRUD proto with all fields', () => {
       const createdProto = protos.list.find((p) =>
         p.value.content?.includes('package test;')
       );
-      expect(createdProto).toBeDefined();      
expect(createdProto!.value.id).toBeDefined();
-
+      expect(createdProto).toBeDefined();
+      expect(createdProto?.value.id).toBeDefined();
+       
       createdProtoId = createdProto!.value.id;
 
       // Verify content matches
diff --git a/e2e/tests/protos.crud-required-fields.spec.ts 
b/e2e/tests/protos.crud-required-fields.spec.ts
index f8c8a0773..0310355b3 100644
--- a/e2e/tests/protos.crud-required-fields.spec.ts
+++ b/e2e/tests/protos.crud-required-fields.spec.ts
@@ -74,8 +74,8 @@ test.describe('CRUD proto with required fields only', () => {
         p.value.content?.includes('package test_required')
       );
       expect(createdProto).toBeDefined();
-      expect(createdProto!.value.id).toBeDefined();
-
+      expect(createdProto?.value.id).toBeDefined();
+       
       createdProtoId = createdProto!.value.id;
 
       // Verify content matches
diff --git a/e2e/tests/routes.crud-all-fields.spec.ts 
b/e2e/tests/routes.crud-all-fields.spec.ts
index f4e8dc3e2..4f1604a4b 100644
--- a/e2e/tests/routes.crud-all-fields.spec.ts
+++ b/e2e/tests/routes.crud-all-fields.spec.ts
@@ -49,7 +49,7 @@ test.beforeAll(async () => {
 test('should CRUD route with all fields', async ({ page }) => {
   test.slow();
 
-  const varsSection = page.getByText('Vars').locator('..');
+  const varsSection = page.getByRole('group', { name: 'Match Rules' 
}).getByText('Vars').locator('..');
 
   // Navigate to the route list page
   await routesPom.toIndex(page);
@@ -172,7 +172,8 @@ test('should CRUD route with all fields', async ({ page }) 
=> {
     ).toBeVisible();
 
     // clear the editor, will show JSON format is not valid
-    await uiClearMonacoEditor(page);
+    const realIpEditor = addPluginDialog.locator('.monaco-editor').first();
+    await uiClearMonacoEditor(page, realIpEditor);
     await expect(
       addPluginDialog.getByText('JSON format is not valid')
     ).toBeVisible();
@@ -262,7 +263,8 @@ test('should CRUD route with all fields', async ({ page }) 
=> {
     await expect(status).toHaveValue('Disabled');
 
     // Verify Vars field
-    await expect(varsSection.getByText('arg_name')).toBeVisible();
+    await uiGetMonacoEditor(page, varsSection, false);
+    await expect(varsSection.getByText('arg_name')).toBeVisible({ timeout: 
15000 });
     await expect(varsSection.getByText('json')).toBeVisible();
 
     // Verify Plugins
@@ -333,7 +335,8 @@ test('should CRUD route with all fields', async ({ page }) 
=> {
     ).toHaveValue('200');
 
     // Verify updated Vars field
-    await expect(varsSection.getByText('arg_name')).toBeVisible();
+    await uiGetMonacoEditor(page, varsSection, false);
+    await expect(varsSection.getByText('arg_name')).toBeVisible({ timeout: 
15000 });
     await expect(varsSection.getByText('updated')).toBeVisible();
 
     // Return to list page and verify the route exists
diff --git a/e2e/tests/routes.empty-plugin-config.spec.ts 
b/e2e/tests/routes.empty-plugin-config.spec.ts
new file mode 100644
index 000000000..bff6a2a05
--- /dev/null
+++ b/e2e/tests/routes.empty-plugin-config.spec.ts
@@ -0,0 +1,257 @@
+/**
+ * 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 { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+  uiFillMonacoEditor,
+  uiGetMonacoEditor,
+  uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const routeNameWithEmptyPlugin = randomId('test-route-empty-plugin');
+const routeUri = '/test-empty-plugin';
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('should preserve plugin with empty configuration (key-auth) after edit', 
async ({
+  page,
+}) => {
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+
+  await test.step('create route with key-auth plugin having configuration', 
async () => {
+    await routesPom.getAddRouteBtn(page).click();
+    await routesPom.isAddPage(page);
+
+    // Fill in required fields
+    await page.getByLabel('Name', { exact: true 
}).first().fill(routeNameWithEmptyPlugin);
+    await page.getByLabel('URI', { exact: true }).fill(routeUri);
+
+    // Select HTTP method
+    await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+    await page.getByRole('option', { name: 'GET' }).click();
+
+    // Add upstream nodes
+    const upstreamSection = page.getByRole('group', {
+      name: 'Upstream',
+      exact: true,
+    });
+    await uiFillUpstreamRequiredFields(upstreamSection, {
+      nodes: [
+        { host: 'httpbin.org', port: 80, weight: 1 },
+        { host: 'httpbin.org', port: 80, weight: 1 },
+      ],
+      name: 'test-upstream-empty-plugin',
+    });
+
+    // Add key-auth plugin with some configuration first
+    const selectPluginsBtn = page.getByRole('button', {
+      name: 'Select Plugins',
+    });
+    await selectPluginsBtn.click();
+
+    const selectPluginsDialog = page.getByRole('dialog', {
+      name: 'Select Plugins',
+    });
+    const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+    await searchInput.fill('key-auth');
+
+    await selectPluginsDialog
+      .getByTestId('plugin-key-auth')
+      .getByRole('button', { name: 'Add' })
+      .click();
+
+    // Add plugin with initial configuration
+    const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+    const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+    await uiFillMonacoEditor(page, pluginEditor, '{"hide_credentials": true}');
+    await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+    await expect(addPluginDialog).toBeHidden();
+
+    // Verify the plugin was added
+    const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+    await expect(pluginsSection.getByTestId('plugin-key-auth')).toBeVisible();
+
+    // Submit the form
+    await routesPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, {
+      hasText: 'Add Route Successfully',
+    });
+  });
+
+  await test.step('verify key-auth plugin is visible in detail page', async () 
=> {
+    await routesPom.isDetailPage(page);
+
+    // Verify the plugin is visible
+    await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+
+    // Verify the route name
+    const name = page.getByLabel('Name', { exact: true }).first();
+    await expect(name).toHaveValue(routeNameWithEmptyPlugin);
+  });
+
+  await test.step('edit plugin to have empty configuration and verify it 
persists', async () => {
+    // Click the Edit button in the detail page
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Verify we're in edit mode
+    const nameField = page.getByLabel('Name', { exact: true }).first();
+    await expect(nameField).toBeEnabled();
+
+    // Verify the key-auth plugin is visible in edit mode
+    const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+    const keyAuthPlugin = pluginsSection.getByTestId('plugin-key-auth');
+    await expect(keyAuthPlugin).toBeVisible();
+
+    // Click edit on the plugin
+    await keyAuthPlugin.getByRole('button', { name: 'Edit' }).click();
+
+    // Edit plugin to have empty configuration
+    const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' });
+    const pluginEditor = await uiGetMonacoEditor(page, editPluginDialog);
+
+    // Clear the editor and set empty object
+    await uiFillMonacoEditor(page, pluginEditor, '{}');
+
+    // Save the plugin
+    await editPluginDialog.getByRole('button', { name: 'Save' }).click();
+    await expect(editPluginDialog).toBeHidden();
+
+    // Verify the plugin is still visible after making config empty
+    await expect(keyAuthPlugin).toBeVisible();
+
+    // Update the description field to trigger a change
+    const descriptionField = page.getByLabel('Description').first();
+    await descriptionField.fill('Updated description after emptying key-auth 
config');
+
+    // Click the Save button to save changes
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await saveBtn.click();
+
+    // Verify the update was successful
+    await uiHasToastMsg(page, {
+      hasText: 'success',
+    });
+
+    // Verify we're back in detail view mode
+    await routesPom.isDetailPage(page);
+
+    // Verify the key-auth plugin is STILL visible after save (critical check 
for the fix)
+    await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+  });
+});
+
+test('should preserve plugin with empty configuration (key-auth) during 
creation', async ({
+  page,
+}) => {
+  const routeNameWithInitialEmptyPlugin = 
randomId('test-route-create-empty-plugin');
+  const routeUriCreate = '/test-create-empty-plugin';
+
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+
+  await test.step('create route with key-auth plugin having empty 
configuration', async () => {
+    await routesPom.getAddRouteBtn(page).click();
+    await routesPom.isAddPage(page);
+
+    // Fill in required fields
+    await page.getByLabel('Name', { exact: true 
}).first().fill(routeNameWithInitialEmptyPlugin);
+    await page.getByLabel('URI', { exact: true }).fill(routeUriCreate);
+
+    // Select HTTP method
+    await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+    await page.getByRole('option', { name: 'GET' }).click();
+
+    // Add upstream nodes
+    const upstreamSection = page.getByRole('group', {
+      name: 'Upstream',
+      exact: true,
+    });
+    await uiFillUpstreamRequiredFields(upstreamSection, {
+      nodes: [
+        { host: 'httpbin.org', port: 80, weight: 1 },
+        { host: 'httpbin.org', port: 80, weight: 1 },
+      ],
+      name: 'test-upstream-create-empty-plugin',
+    });
+
+    // Add key-auth plugin with empty configuration
+    const selectPluginsBtn = page.getByRole('button', {
+      name: 'Select Plugins',
+    });
+    await selectPluginsBtn.click();
+
+    const selectPluginsDialog = page.getByRole('dialog', {
+      name: 'Select Plugins',
+    });
+    const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+    await searchInput.fill('key-auth');
+
+    await selectPluginsDialog
+      .getByTestId('plugin-key-auth')
+      .getByRole('button', { name: 'Add' })
+      .click();
+
+    // Add plugin with empty configuration
+    const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+    const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+    await uiFillMonacoEditor(page, pluginEditor, '{}');
+    await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+    await expect(addPluginDialog).toBeHidden();
+
+    // Verify the plugin was added in the form
+    const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+    await expect(pluginsSection.getByTestId('plugin-key-auth')).toBeVisible();
+
+    // Submit the form
+    await routesPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, {
+      hasText: 'Add Route Successfully',
+    });
+  });
+
+  await test.step('verify key-auth plugin is visible in detail page after 
creation', async () => {
+    await routesPom.isDetailPage(page);
+
+    // Verify the plugin is visible
+    await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+
+    // Verify the route name
+    const name = page.getByLabel('Name', { exact: true }).first();
+    await expect(name).toHaveValue(routeNameWithInitialEmptyPlugin);
+
+    // Open the plugin in view mode and verify its config is the empty object 
{}
+    const pluginCard = page.getByTestId('plugin-key-auth');
+    await pluginCard.getByRole('button', { name: 'View' }).click();
+    const viewPluginDialog = page.getByRole('dialog', { name: 'View Plugin' });
+    await uiGetMonacoEditor(page, viewPluginDialog, false);
+    // Read Monaco editor content from the visible lines (not the textarea)
+    const viewContent = await viewPluginDialog
+      .locator('.view-lines')
+      .first()
+      .innerText();
+    expect(viewContent.replace(/\s/g, '')).toBe('{}');
+  });
+});
diff --git a/e2e/tests/services.crud-required-fields.spec.ts 
b/e2e/tests/services.crud-required-fields.spec.ts
index ede2ac2c6..cc94d91c9 100644
--- a/e2e/tests/services.crud-required-fields.spec.ts
+++ b/e2e/tests/services.crud-required-fields.spec.ts
@@ -57,6 +57,9 @@ test('should CRUD service with required fields', async ({ 
page }) => {
     await rows.first().locator('input').first().fill('127.0.0.1');
     await rows.first().locator('input').nth(1).fill('80');
     await rows.first().locator('input').nth(2).fill('1');
+    await rows.first().locator('input').nth(2).blur();
+    // eslint-disable-next-line playwright/no-wait-for-timeout
+    await page.waitForTimeout(500);
 
     // Ensure the name field is properly filled before submitting
     const nameField = page.getByRole('textbox', { name: 'Name' }).first();
diff --git a/e2e/tests/services.stream_routes.crud.spec.ts 
b/e2e/tests/services.stream_routes.crud.spec.ts
index cb169e333..e559decb0 100644
--- a/e2e/tests/services.stream_routes.crud.spec.ts
+++ b/e2e/tests/services.stream_routes.crud.spec.ts
@@ -129,7 +129,6 @@ test('should CRUD stream route under service', async ({ 
page }) => {
     const saveBtn = page.getByRole('button', { name: 'Save' });
     await saveBtn.click();
 
-    // Verify the update was successful
     await uiHasToastMsg(page, {
       hasText: 'success',
     });
diff --git a/e2e/tests/stream_routes.crud-all-fields.spec.ts 
b/e2e/tests/stream_routes.crud-all-fields.spec.ts
index 4da6fef3b..1d7a94bbe 100644
--- a/e2e/tests/stream_routes.crud-all-fields.spec.ts
+++ b/e2e/tests/stream_routes.crud-all-fields.spec.ts
@@ -66,14 +66,17 @@ test('CRUD stream route with all fields', async ({ page }) 
=> {
   const hostInput = firstRow.locator('input').nth(0);
   await hostInput.click();
   await hostInput.fill('127.0.0.11');
+  await hostInput.press('Tab');
 
   const portInput = firstRow.locator('input').nth(1);
   await portInput.click();
   await portInput.fill('8081');
+  await portInput.press('Tab');
 
   const weightInput = firstRow.locator('input').nth(2);
   await weightInput.click();
   await weightInput.fill('100');
+  await weightInput.press('Tab');
 
   // Submit and land on detail page
   await page.getByRole('button', { name: 'Add', exact: true }).click();
diff --git a/e2e/tests/stream_routes.crud-required-fields.spec.ts 
b/e2e/tests/stream_routes.crud-required-fields.spec.ts
index 94593c1c9..2bb7a2d9a 100644
--- a/e2e/tests/stream_routes.crud-required-fields.spec.ts
+++ b/e2e/tests/stream_routes.crud-required-fields.spec.ts
@@ -59,14 +59,17 @@ test('CRUD stream route with required fields', async ({ 
page }) => {
   const hostInput = firstRow.locator('input').nth(0);
   await hostInput.click();
   await hostInput.fill('127.0.0.2');
+  await hostInput.press('Tab');
 
   const portInput = firstRow.locator('input').nth(1);
   await portInput.click();
   await portInput.fill('8080');
+  await portInput.press('Tab');
 
   const weightInput = firstRow.locator('input').nth(2);
   await weightInput.click();
   await weightInput.fill('1');
+  await weightInput.press('Tab');
 
   // Submit and land on detail page
   await page.getByRole('button', { name: 'Add', exact: true }).click();
diff --git a/e2e/tests/upstreams.empty-discovery-args.spec.ts 
b/e2e/tests/upstreams.empty-discovery-args.spec.ts
new file mode 100644
index 000000000..3f08fbff9
--- /dev/null
+++ b/e2e/tests/upstreams.empty-discovery-args.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * 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 { upstreamsPom } from '@e2e/pom/upstreams';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import { API_UPSTREAMS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+/**
+ * Test that editing an upstream with service discovery and empty 
discovery_args
+ * does not cause the discovery_args field to be dropped.
+ * Regression test for: https://github.com/apache/apisix-dashboard/issues/3270
+ */
+const upstream: APISIXType['Upstream'] = {
+  id: 'upstream_discovery_test',
+  name: 'upstream_discovery_test',
+  desc: 'Upstream with service discovery',
+  discovery_type: 'nacos',
+  service_name: 'test-service',
+  discovery_args: {},
+};
+
+test.beforeAll(async () => {
+  await deleteAllUpstreams(e2eReq);
+  await putUpstreamReq(e2eReq, upstream);
+});
+
+test.afterAll(async () => {
+  await e2eReq.delete(`${API_UPSTREAMS}/${upstream.id}`);
+});
+
+test('should preserve empty discovery_args after editing upstream', async ({
+  page,
+}) => {
+  await test.step('verify upstream exists and has discovery fields', async () 
=> {
+    // Verify upstream was created with discovery_args via API
+    const resp = await e2eReq.get(`${API_UPSTREAMS}/${upstream.id}`);
+    const data = resp.data;
+    expect(data.value.discovery_type).toBe('nacos');
+    expect(data.value.service_name).toBe('test-service');
+    expect(data.value.discovery_args).toEqual({});
+  });
+
+  await test.step('navigate to upstream detail page', async () => {
+    await upstreamsPom.toIndex(page);
+    await upstreamsPom.isIndexPage(page);
+
+    // Click on the upstream to view details
+    const row = page.locator('tr').filter({ hasText: upstream.name });
+    await expect(row).toBeVisible();
+    await row.getByRole('button', { name: 'View' }).click();
+    await upstreamsPom.isDetailPage(page);
+  });
+
+  await test.step('edit upstream description and save', async () => {
+    // Click Edit button
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Verify we're in edit mode
+    const nameField = page.getByLabel('Name', { exact: true }).first();
+    await expect(nameField).toBeEnabled();
+
+    // Verify discovery fields are visible
+    const discoverySection = page.getByRole('group', {
+      name: 'Service Discovery',
+    });
+    await expect(discoverySection).toBeVisible();
+
+    // Update the description to trigger a change
+    const descriptionField = page.getByLabel('Description').first();
+    await descriptionField.fill(
+      'Updated description to verify discovery_args persists'
+    );
+
+    // Save the changes
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await saveBtn.click();
+
+    // Verify the update was successful
+    await uiHasToastMsg(page, {
+      hasText: 'success',
+    });
+
+    // Verify we're back in detail view mode
+    await upstreamsPom.isDetailPage(page);
+  });
+
+  await test.step('verify discovery_args is preserved after edit', async () => 
{
+    // Verify via API that discovery_args was not dropped
+    const resp = await e2eReq.get(`${API_UPSTREAMS}/${upstream.id}`);
+    const data = resp.data;
+    expect(data.value.discovery_type).toBe('nacos');
+    expect(data.value.service_name).toBe('test-service');
+    expect(data.value.discovery_args).toEqual({});
+    expect(data.value.desc).toBe(
+      'Updated description to verify discovery_args persists'
+    );
+  });
+});
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index cfb491690..764a78542 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -41,7 +41,15 @@ export const uiHasToastMsg = async (
 ) => {
   const alertMsg = page.getByRole('alert').filter(...filterOpts);
   // Increased timeout for CI environment (30s instead of default 5s)
-  await expect(alertMsg).toBeVisible({ timeout: 30000 });
+  try {
+    await expect(alertMsg).toBeVisible({ timeout: 25000 });
+  } catch (e) {
+    const alerts = await page.getByRole('alert').all();
+    const texts = await Promise.all(alerts.map((a) => a.textContent()));
+    console.error(`Failed to find toast with filter options: 
${JSON.stringify(filterOpts)}`);
+    console.error(`Currently visible alerts: ${JSON.stringify(texts)}`);
+    throw e;
+  }
   await alertMsg.getByRole('button').click();
   await expect(alertMsg).not.toBeVisible();
 };
@@ -64,11 +72,18 @@ export async function uiFillHTTPStatuses(
   }
 }
 
-export const uiClearMonacoEditor = async (page: Page) => {
-  await page.evaluate(() => {
-    const editor = window.__monacoEditor__;
-    editor.getModel()?.setValue('');
-  });
+export const uiClearMonacoEditor = async (page: Page, editor?: Locator) => {
+  if (editor) {
+    // Use the specific editor element to find its Monaco instance
+    await editor.click();
+    await page.keyboard.press('Control+A');
+    await page.keyboard.press('Delete');
+  } else {
+    await page.evaluate(() => {
+      const ed = window.__monacoEditor__;
+      ed.getModel()?.setValue('');
+    });
+  }
 };
 
 export const uiGetMonacoEditor = async (
@@ -83,7 +98,7 @@ export const uiGetMonacoEditor = async (
   await expect(editor).toBeVisible({ timeout: 10000 });
 
   if (clear) {
-    await uiClearMonacoEditor(page);
+    await uiClearMonacoEditor(page, editor);
   }
 
   return editor;
diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts
index 9def978d9..21a26c44f 100644
--- a/e2e/utils/ui/upstreams.ts
+++ b/e2e/utils/ui/upstreams.ts
@@ -63,7 +63,15 @@ export async function uiFillUpstreamRequiredFields(
 
   // Add a third node and then remove it to test deletion functionality
   await secondRowHost.blur();
-  if (page) await page.waitForTimeout(500);
+  let p: Page;
+  if (
+    typeof (ctx as Locator).page === 'function'
+  ) {
+    p = (ctx as Locator).page();
+  } else {
+    p = ctx as Page;
+  }
+  await p.waitForTimeout(500);
   await addNodeBtn.click();
   await expect(rows).toHaveCount(3, { timeout: 10000 });
   await rows.nth(2).getByRole('button', { name: 'Delete' }).click();
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx 
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index 8a72b8122..1547af8c7 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -80,6 +80,7 @@ export const PluginEditorDrawer = (props: 
PluginEditorDrawerProps) => {
             customSchema={schema}
             isLoading={!schema}
             required
+            disabled={mode === 'view'}
           />
         </form>
 
diff --git a/src/components/form-slice/FormPartRoute/util.ts 
b/src/components/form-slice/FormPartRoute/util.ts
index 7b406f7e7..951880333 100644
--- a/src/components/form-slice/FormPartRoute/util.ts
+++ b/src/components/form-slice/FormPartRoute/util.ts
@@ -16,6 +16,7 @@
  */
 import { produce } from 'immer';
 
+import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
 import { pipeProduce } from '@/utils/producer';
 
@@ -35,5 +36,6 @@ export const produceVarsToAPI = produce((draft: 
RoutePostType) => {
 
 export const produceRoute = pipeProduce(
   produceRmUpstreamWhenHas('service_id', 'upstream_id'),
+  produceRmEmptyUpstreamFields,
   produceVarsToAPI
 );
diff --git a/src/components/form-slice/FormPartRoute/util.ts 
b/src/components/form-slice/FormPartStreamRoute/util.ts
similarity index 53%
copy from src/components/form-slice/FormPartRoute/util.ts
copy to src/components/form-slice/FormPartStreamRoute/util.ts
index 7b406f7e7..1b26d84dd 100644
--- a/src/components/form-slice/FormPartRoute/util.ts
+++ b/src/components/form-slice/FormPartStreamRoute/util.ts
@@ -15,25 +15,28 @@
  * limitations under the License.
  */
 import { produce } from 'immer';
+import { pipe } from 'rambdax';
 
+import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
-import { pipeProduce } from '@/utils/producer';
 
-import type { RoutePostType, RoutePutType } from './schema';
+import type { StreamRoutePostType } from './schema';
 
-export const produceVarsToForm = produce((draft: RoutePostType) => {
-  if (draft.vars && Array.isArray(draft.vars)) {
-    draft.vars = JSON.stringify(draft.vars);
-  }
-}) as (draft: RoutePostType) => RoutePutType;
+export const produceStreamRoute = (val: StreamRoutePostType) =>
+  pipe(
+    produceRmEmptyUpstreamFields,
+    (produceRmUpstreamWhenHas('service_id', 'upstream_id') as unknown as (
+      d: StreamRoutePostType
+    ) => StreamRoutePostType),
+    produce((draft: StreamRoutePostType) => {
+      // Stream Routes do not support name and status
+      const d = draft as StreamRoutePostType & { name?: string; status?: 
number };
+      delete d.name;
+      delete d.status;
 
-export const produceVarsToAPI = produce((draft: RoutePostType) => {
-  if (draft.vars && typeof draft.vars === 'string') {
-    draft.vars = JSON.parse(draft.vars);
-  }
-});
-
-export const produceRoute = pipeProduce(
-  produceRmUpstreamWhenHas('service_id', 'upstream_id'),
-  produceVarsToAPI
-);
+      // Cleanup protocol if name is missing
+      if (draft.protocol && !draft.protocol.name) {
+        delete draft.protocol;
+      }
+    })
+  )(val);
diff --git 
a/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx 
b/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
index 7eb04aa3f..b40cab00c 100644
--- a/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
@@ -45,6 +45,7 @@ export const FormSectionDiscovery = () => {
         label={t('form.upstreams.discoveryArgs.title')}
         control={control}
         toObject
+        shouldUnregister={false}
       />
     </FormSection>
   );
diff --git a/src/components/form-slice/FormPartUpstream/util.ts 
b/src/components/form-slice/FormPartUpstream/util.ts
index f397a7ce7..f4bf2b0f9 100644
--- a/src/components/form-slice/FormPartUpstream/util.ts
+++ b/src/components/form-slice/FormPartUpstream/util.ts
@@ -31,3 +31,53 @@ export const produceToUpstreamForm = (
     d.__checksPassiveEnabled =
       !!upstream.checks?.passive && isNotEmpty(upstream.checks.passive);
   });
+
+const isAllUndefined = (obj: Record<string, unknown>) =>
+  Object.values(obj).every(
+    (v) => v === undefined || v === null || v === '' || Number.isNaN(v)
+  );
+
+export const produceRmEmptyUpstreamFields = produce(
+  (
+    draft: {
+      timeout?: Record<string, unknown>;
+      keepalive_pool?: Record<string, unknown>;
+      tls?: Record<string, unknown>;
+      upstream?: {
+        timeout?: Record<string, unknown>;
+        keepalive_pool?: Record<string, unknown>;
+        tls?: Record<string, unknown>;
+      };
+    } & Record<string, unknown>
+  ) => {
+    if (draft.timeout && isAllUndefined(draft.timeout)) {
+      delete draft.timeout;
+    }
+    if (draft.keepalive_pool && isAllUndefined(draft.keepalive_pool)) {
+      delete draft.keepalive_pool;
+    }
+    if (draft.tls && isAllUndefined(draft.tls)) {
+      delete draft.tls;
+    }
+
+    if (draft.upstream) {
+      const u = draft.upstream as Record<string, unknown>;
+      delete u.name;
+      delete u.desc;
+      delete u.labels;
+
+      if (draft.upstream.timeout && isAllUndefined(draft.upstream.timeout)) {
+        delete draft.upstream.timeout;
+      }
+      if (
+        draft.upstream.keepalive_pool &&
+        isAllUndefined(draft.upstream.keepalive_pool)
+      ) {
+        delete draft.upstream.keepalive_pool;
+      }
+      if (draft.upstream.tls && isAllUndefined(draft.upstream.tls)) {
+        delete draft.upstream.tls;
+      }
+    }
+  }
+);
diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx
index 1139ca414..f621ca313 100644
--- a/src/components/form/Editor.tsx
+++ b/src/components/form/Editor.tsx
@@ -58,7 +58,7 @@ export const FormItemEditor = <T extends FieldValues>(
   const { controllerProps, restProps } = genControllerProps(props, '');
   const { customSchema, language, isLoading, required, ...wrapperProps } =
     restProps;
-  const { trigger } = useFormContext();
+  const { trigger, watch } = useFormContext();
   const monacoErrorRef = useRef<string | null>(null);
   const enhancedControllerProps = useMemo(() => {
     return {
@@ -86,10 +86,23 @@ export const FormItemEditor = <T extends FieldValues>(
   }, [controllerProps, required, t, monacoErrorRef]);
 
   const {
-    field: { value, onChange: fOnChange, ...restField },
+    field: { value: controlledValue, onChange: fOnChange, ...restField },
     fieldState,
   } = useController<T>(enhancedControllerProps);
 
+  // useController returns undefined for disabled fields in react-hook-form.
+  // useFormContext().watch() reads from both _formValues and _defaultValues,
+  // making it reliable even when the form is disabled with 
shouldUnregister:true.
+  const watchedValue = watch(controllerProps.name as string);
+  // watchedValue may be a non-string (e.g. raw array) if the form store has
+  // the pre-transform value. Normalize to a JSON string for Monaco.
+  const normalizedWatchedValue = typeof watchedValue === 'string'
+    ? watchedValue
+    : watchedValue != null
+      ? JSON.stringify(watchedValue, null, 2)
+      : undefined;
+  const value = restField.disabled ? normalizedWatchedValue ?? '' : 
controlledValue;
+
   const [internalLoading, setLoading] = useState(false);
 
   useEffect(() => {
diff --git a/src/components/form/JsonInput.tsx 
b/src/components/form/JsonInput.tsx
index 206289eb8..c848051a8 100644
--- a/src/components/form/JsonInput.tsx
+++ b/src/components/form/JsonInput.tsx
@@ -70,6 +70,7 @@ export const FormItemJsonInput = <T extends FieldValues>(
       formatOnBlur
       autosize
       resize="vertical"
+      validationError={null}
       {...restField}
       {...omit(['objValue'], restProps)}
     />
diff --git a/src/config/req.ts b/src/config/req.ts
index 2f136c9c1..078bd513f 100644
--- a/src/config/req.ts
+++ b/src/config/req.ts
@@ -79,9 +79,10 @@ req.interceptors.response.use(
       if (matchSkipInterceptor(err)) return Promise.reject(err);
       const res = err.response as AxiosResponse<APISIXRespErr>;
       const d = res.data;
+      const message = d?.error_msg || d?.message || `Error status: 
${res.status}`;
       notifications.show({
-        id: d?.error_msg || d?.message,
-        message: d?.error_msg || d?.message,
+        id: message,
+        message,
         color: 'red',
       });
       // Requires to enter admin key at 401
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index ca49bf2de..48f124783 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -23,7 +23,7 @@ import {
   useNavigate,
   useParams,
 } from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { useBoolean } from 'react-use';
@@ -62,6 +62,16 @@ const RouteDetailForm = (props: Props) => {
   const routeQuery = useQuery(getRouteQueryOptions(id));
   const { data: routeData, isLoading, refetch } = routeQuery;
 
+  const formDefaults = useMemo(() => {
+    if (routeData?.value) {
+      const upstreamProduced = produceToUpstreamForm(
+        routeData.value.upstream || {},
+        routeData.value
+      );
+      return produceVarsToForm(upstreamProduced);
+    }
+  }, [routeData]);
+
   const form = useForm({
     resolver: zodResolver(RoutePutSchema),
     shouldUnregister: true,
@@ -71,14 +81,10 @@ const RouteDetailForm = (props: Props) => {
   });
 
   useEffect(() => {
-    if (routeData?.value && !isLoading) {
-      const upstreamProduced = produceToUpstreamForm(
-        routeData.value.upstream || {},
-        routeData.value
-      );
-      form.reset(produceVarsToForm(upstreamProduced));
+    if (formDefaults && !isLoading) {
+      form.reset(formDefaults);
     }
-  }, [routeData, form, isLoading]);
+  }, [formDefaults, form, isLoading]);
 
   const putRoute = useMutation({
     mutationFn: (d: RoutePutType) =>
diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx
index e6d7ec3ca..28498edad 100644
--- a/src/routes/services/add.tsx
+++ b/src/routes/services/add.tsx
@@ -25,6 +25,7 @@ import { postServiceReq, type ServicePostType } from 
'@/apis/services';
 import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartService } from '@/components/form-slice/FormPartService';
 import { ServicePostSchema } from 
'@/components/form-slice/FormPartService/schema';
+import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
@@ -39,7 +40,7 @@ const ServiceAddForm = () => {
     mutationFn: (d: ServicePostType) =>
       postServiceReq(
         req,
-        pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+        pipeProduce(produceRmUpstreamWhenHas('upstream_id'), 
produceRmEmptyUpstreamFields)(d) as ServicePostType
       ),
     async onSuccess(res) {
       notifications.show({
@@ -62,7 +63,7 @@ const ServiceAddForm = () => {
 
   return (
     <FormProvider {...form}>
-      <form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d))}>
+      <form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d as 
ServicePostType))}>
         <FormPartService />
         <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
       </form>
diff --git a/src/routes/services/detail.$id/index.tsx 
b/src/routes/services/detail.$id/index.tsx
index e64f9188d..4e4a518de 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -32,6 +32,7 @@ import { getServiceQueryOptions } from '@/apis/hooks';
 import { putServiceReq } from '@/apis/services';
 import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartService } from '@/components/form-slice/FormPartService';
+import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
@@ -73,7 +74,7 @@ const ServiceDetailForm = (props: Props) => {
     mutationFn: (d: APISIXType['Service']) =>
       putServiceReq(
         req,
-        pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+        pipeProduce(produceRmUpstreamWhenHas('upstream_id'), 
produceRmEmptyUpstreamFields)(d)
       ),
     async onSuccess() {
       notifications.show({
diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx
index 39f02ebd7..6364ad03a 100644
--- a/src/routes/stream_routes/add.tsx
+++ b/src/routes/stream_routes/add.tsx
@@ -23,12 +23,12 @@ import { useTranslation } from 'react-i18next';
 
 import { postStreamRouteReq } from '@/apis/stream_routes';
 import { FormSubmitBtn } from '@/components/form/Btn';
-import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
 import { FormPartStreamRoute } from 
'@/components/form-slice/FormPartStreamRoute';
 import {
   StreamRoutePostSchema,
   type StreamRoutePostType,
 } from '@/components/form-slice/FormPartStreamRoute/schema';
+import { produceStreamRoute } from 
'@/components/form-slice/FormPartStreamRoute/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { StreamRoutesErrorComponent } from 
'@/components/page-slice/stream_routes/ErrorComponent';
@@ -46,7 +46,7 @@ export const StreamRouteAddForm = (props: Props) => {
 
   const postStreamRoute = useMutation({
     mutationFn: (d: StreamRoutePostType) =>
-      postStreamRouteReq(req, produceRoute(d)),
+      postStreamRouteReq(req, produceStreamRoute(d)),
     async onSuccess(res) {
       notifications.show({
         message: t('info.add.success', { name: t('streamRoutes.singular') }),
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index a080b815e..28d2150e0 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -26,6 +26,7 @@ import { postUpstreamReq } from '@/apis/upstreams';
 import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
 import { FormPartUpstreamSchema } from 
'@/components/form-slice/FormPartUpstream/schema';
+import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
@@ -41,7 +42,7 @@ const UpstreamAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
   const postUpstream = useMutation({
-    mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, d),
+    mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, 
pipeProduce(produceRmEmptyUpstreamFields)(d) as PostUpstreamType),
     async onSuccess(data) {
       notifications.show({
         message: t('info.add.success', { name: t('upstreams.singular') }),
@@ -61,11 +62,7 @@ const UpstreamAddForm = () => {
 
   return (
     <FormProvider {...form}>
-      <form
-        onSubmit={form.handleSubmit((d) =>
-          postUpstream.mutateAsync(pipeProduce()(d))
-        )}
-      >
+      <form onSubmit={form.handleSubmit((d) => postUpstream.mutateAsync(d as 
PostUpstreamType))}>
         <FormPartUpstream />
         <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
       </form>
diff --git a/src/routes/upstreams/detail.$id.tsx 
b/src/routes/upstreams/detail.$id.tsx
index ca5f8e9c4..9ed24dda9 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
-import { Button, Group,Skeleton } from '@mantine/core';
+import { Button, Group, Skeleton } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import {
   queryOptions,
@@ -27,7 +27,7 @@ import {
   useNavigate,
   useParams,
 } from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { useBoolean } from 'react-use';
@@ -36,7 +36,10 @@ import { getUpstreamReq, putUpstreamReq } from 
'@/apis/upstreams';
 import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
 import { FormPartUpstreamSchema } from 
'@/components/form-slice/FormPartUpstream/schema';
-import { produceToUpstreamForm } from 
'@/components/form-slice/FormPartUpstream/util';
+import {
+  produceRmEmptyUpstreamFields,
+  produceToUpstreamForm,
+} from '@/components/form-slice/FormPartUpstream/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
@@ -68,6 +71,11 @@ const UpstreamDetailForm = (
     refetch,
   } = useSuspenseQuery(getUpstreamQueryOptions(id));
 
+  const formDefaults = useMemo(
+    () => produceToUpstreamForm(upstreamData),
+    [upstreamData]
+  );
+
   const form = useForm({
     resolver: zodResolver(FormPartUpstreamSchema),
     shouldUnregister: true,
@@ -76,7 +84,21 @@ const UpstreamDetailForm = (
   });
 
   const putUpstream = useMutation({
-    mutationFn: (d: APISIXType['Upstream']) => putUpstreamReq(req, d),
+    mutationFn: (d: APISIXType['Upstream']) => {
+      // Merge original discovery_args into form data before processing,
+      // so pipeProduce's produceRestoreEmptyPlugins can restore 
discovery_args: {}
+      // even if the field was not touched (and thus absent from d).
+      const merged = {
+        ...d,
+        ...(upstreamData.discovery_args !== undefined && !('discovery_args' in 
d)
+          ? { discovery_args: upstreamData.discovery_args }
+          : {}),
+      };
+      return putUpstreamReq(
+        req,
+        pipeProduce(produceRmEmptyUpstreamFields)(merged) as 
APISIXType['Upstream']
+      );
+    },
     async onSuccess() {
       notifications.show({
         message: t('info.edit.success', { name: t('upstreams.singular') }),
@@ -89,9 +111,9 @@ const UpstreamDetailForm = (
 
   useEffect(() => {
     if (upstreamData && !isLoading) {
-      form.reset(produceToUpstreamForm(upstreamData));
+      form.reset(formDefaults);
     }
-  }, [upstreamData, form, isLoading]);
+  }, [formDefaults, form, isLoading, upstreamData]);
 
   if (isLoading) {
     return <Skeleton height={400} />;
@@ -102,7 +124,7 @@ const UpstreamDetailForm = (
       <FormProvider {...form}>
         <form
           onSubmit={form.handleSubmit((d) => {
-            putUpstream.mutateAsync(pipeProduce()(d));
+            putUpstream.mutateAsync(d as APISIXType['Upstream']);
           })}
         >
           <FormSectionGeneral readOnly />
diff --git a/src/utils/producer.ts b/src/utils/producer.ts
index 588b5edb4..2d4e82e15 100644
--- a/src/utils/producer.ts
+++ b/src/utils/producer.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { clean,type ICleanerOptions } from 'fast-clean';
+import { clean, type ICleanerOptions } from 'fast-clean';
 import { produce } from 'immer';
 import { pipe } from 'rambdax';
 
@@ -35,6 +35,38 @@ export const produceDeepCleanEmptyKeys = (opts: 
ICleanerOptions = {}) =>
     deepCleanEmptyKeys(draft, opts);
   });
 
+/**
+ * Preserves plugin entries with empty config ({}) after deep cleaning.
+ * APISIX plugins like key-auth have no required fields and are valid with {}.
+ * deepCleanEmptyKeys would strip them, so we restore them from the original.
+ */
+export const produceRestoreEmptyPlugins = (original: object) =>
+  produce((draft: Record<string, unknown>) => {
+    const orig = original as Record<string, unknown>;
+    if (orig.plugins && typeof orig.plugins === 'object') {
+      const origPlugins = orig.plugins as Record<string, unknown>;
+      const draftPlugins = (draft.plugins ?? {}) as Record<string, unknown>;
+      Object.keys(origPlugins).forEach((name) => {
+        if (!(name in draftPlugins)) {
+          draftPlugins[name] = origPlugins[name];
+        }
+      });
+      if (Object.keys(draftPlugins).length > 0) {
+        draft.plugins = draftPlugins;
+      }
+    }
+    // Restore discovery_args: {} if it was present in the original.
+    // APISIX accepts empty discovery_args and deepCleanEmptyKeys would strip 
it.
+    if (
+      'discovery_args' in orig &&
+      orig.discovery_args !== null &&
+      typeof orig.discovery_args === 'object' &&
+      Object.keys(orig.discovery_args as object).length === 0
+    ) {
+      (draft as Record<string, unknown>).discovery_args = {};
+    }
+  });
+
 export const rmDoubleUnderscoreKeys = (obj: object) => {
   Object.keys(obj).forEach((key) => {
     const k = key as keyof typeof obj;
@@ -64,7 +96,8 @@ export const pipeProduce = (...funcs: ((a: any) => 
unknown)[]) => {
         ...fs,
         produceRmDoubleUnderscoreKeys,
         produceTime,
-        produceDeepCleanEmptyKeys()
+        produceDeepCleanEmptyKeys(),
+        produceRestoreEmptyPlugins(val as object)
       )(draft) as never;
     }) as T;
 };

Reply via email to