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

LiteSun 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 612d11995 fix: do not send an inline upstream the user never touched 
(#3477)
612d11995 is described below

commit 612d11995bc1ccf2feb820fb5cc8deb5bed8a72e
Author: litesun <[email protected]>
AuthorDate: Fri Oct 16 15:02:10 2026 +0800

    fix: do not send an inline upstream the user never touched (#3477)
---
 e2e/server/Dockerfile                              |   8 +-
 .../routes.plugins-only-no-upstream.spec.ts        | 172 +++++++++++++
 .../form-slice/FormPartUpstream/util.test.ts       | 270 +++++++++++++++++++++
 src/components/form-slice/FormPartUpstream/util.ts |  72 +++++-
 src/components/form/Editor.tsx                     |   8 +-
 src/hooks/useEditCancelGuard.tsx                   |  13 +-
 src/hooks/useFormBaseline.ts                       |  56 +++++
 src/hooks/useUnsavedChangesGuard.tsx               |  18 +-
 8 files changed, 598 insertions(+), 19 deletions(-)

diff --git a/e2e/server/Dockerfile b/e2e/server/Dockerfile
index 06f6d84c4..cd182a350 100644
--- a/e2e/server/Dockerfile
+++ b/e2e/server/Dockerfile
@@ -28,13 +28,17 @@ RUN \
 
 # build ui
 FROM base AS builder
-ENV NODE_ENV=test
 WORKDIR /app
 COPY --from=deps /app/node_modules ./node_modules
 COPY . .
 # we need git to get commit sha
 RUN apk add --no-cache git
-RUN corepack enable pnpm && pnpm build
+# `--mode test` (not NODE_ENV=test): the tests must run against the same
+# production React the release ships. NODE_ENV=test made Vite bundle
+# development React, whose StrictMode double effects wiped the form
+# widgets' mount-time values and hid #3395 from every e2e run; the Vite
+# mode still hides the devtools and enables the Monaco test seam.
+RUN corepack enable pnpm && pnpm build --mode test
 
 # embed dashboard ui into apisix
 FROM apache/apisix:dev
diff --git a/e2e/tests/regression/routes.plugins-only-no-upstream.spec.ts 
b/e2e/tests/regression/routes.plugins-only-no-upstream.spec.ts
new file mode 100644
index 000000000..9434156d3
--- /dev/null
+++ b/e2e/tests/regression/routes.plugins-only-no-upstream.spec.ts
@@ -0,0 +1,172 @@
+/**
+ * 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.
+ */
+
+// Regression: a plugins-only route (no upstream at all) must be creatable
+// and editable from the Dashboard.
+// Related issue:
+//   - apache/apisix-dashboard#3395 bind the route to the public-api plugin,
+//     upstream is a required option in the dashboard, but it is not
+//     actually needed
+// The Admin API accepts a route with only `uri` + `plugins.public-api`
+// (the official public-api example exposes /apisix/prometheus/metrics that
+// way). The Dashboard's Route form always renders the inline upstream
+// section, and its Select controls register defaults (scheme / type /
+// hash_on / pass_host) the moment they mount — so an untouched upstream
+// section must NOT turn into a half-built `upstream` object in the request
+// body, which the Admin API rejects (upstream needs nodes or discovery).
+
+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,
+  uiGoto,
+  uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { expect, type Page, type Request } from '@playwright/test';
+
+import { deleteAllRoutes, getRouteReq, putRouteReq } from '@/apis/routes';
+import { API_PREFIX, API_ROUTES } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const pluginName = 'public-api';
+
+const captureRouteWrites = (page: Page) => {
+  const bodies: Record<string, unknown>[] = [];
+  const isRouteWrite = (req: Request) =>
+    ['POST', 'PUT', 'PATCH'].includes(req.method()) &&
+    req.url().includes(`${API_PREFIX}${API_ROUTES}`);
+  page.on('request', (req) => {
+    if (isRouteWrite(req)) {
+      bodies.push(req.postDataJSON() as Record<string, unknown>);
+    }
+  });
+  return bodies;
+};
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('add page: a route with only uri + public-api and no upstream is 
accepted', async ({
+  page,
+}) => {
+  const routeName = randomId('reg-plugins-only');
+  const routeUri = `/regression/plugins-only/${routeName}`;
+  const bodies = captureRouteWrites(page);
+
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await test.step('fill name, uri and the public-api plugin only', async () => 
{
+    await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+    await page.getByLabel('URI', { exact: true }).fill(routeUri);
+
+    await page.getByRole('button', { name: 'Select Plugins' }).click();
+    const selectPluginsDialog = page.getByRole('dialog', {
+      name: 'Select Plugins',
+    });
+    await selectPluginsDialog.getByPlaceholder('Search').fill(pluginName);
+    await selectPluginsDialog
+      .getByTestId(`plugin-${pluginName}`)
+      .getByRole('button', { name: 'Add' })
+      .click();
+
+    const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+    const editor = await uiGetMonacoEditor(page, addPluginDialog);
+    await uiFillMonacoEditor(page, editor, '{}');
+    await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+    await expect(addPluginDialog).toBeHidden();
+    await expect(
+      page
+        .getByRole('group', { name: 'Plugins' })
+        .getByTestId(`plugin-${pluginName}`)
+    ).toBeVisible();
+  });
+
+  await test.step('submit succeeds and lands on the detail page', async () => {
+    await routesPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+    await routesPom.isDetailPage(page);
+  });
+
+  await test.step('request body carried no upstream object', async () => {
+    expect(bodies).toHaveLength(1);
+    expect(bodies[0]).not.toHaveProperty('upstream');
+    expect(bodies[0]).not.toHaveProperty('upstream_id');
+    expect(bodies[0]).toHaveProperty(['plugins', pluginName]);
+  });
+
+  await test.step('Admin API stored a plugins-only route', async () => {
+    const routeId = page.url().split('/').pop()!;
+    const route = (await getRouteReq(e2eReq, routeId))
+      .value as APISIXType['Route'];
+    expect(route.uri).toBe(routeUri);
+    expect(route.plugins).toHaveProperty(pluginName);
+    expect(route.upstream).toBeUndefined();
+    expect(route.upstream_id).toBeUndefined();
+    expect(route.service_id).toBeUndefined();
+  });
+});
+
+test('edit page: a no-op save keeps an API-created plugins-only route 
upstream-free', async ({
+  page,
+}) => {
+  const routeName = randomId('reg-plugins-only-edit');
+  const routeUri = `/regression/plugins-only-edit/${routeName}`;
+  const res = await putRouteReq(e2eReq, {
+    id: routeName,
+    name: routeName,
+    uri: routeUri,
+    plugins: { [pluginName]: {} },
+  } as APISIXType['Route']);
+  const routeId = res.data.value.id;
+  const bodies = captureRouteWrites(page);
+
+  await uiGoto(page, '/routes/detail/$id', { id: routeId });
+  await routesPom.isDetailPage(page);
+
+  await test.step('enter edit mode and save without changes', async () => {
+    await page.getByRole('button', { name: 'Edit' }).click();
+    await expect(
+      page.getByLabel('Name', { exact: true }).first()
+    ).toBeEnabled();
+    await page.getByRole('button', { name: 'Save' }).click();
+    await uiHasToastMsg(page, { hasText: 'success' });
+    await routesPom.isDetailPage(page);
+  });
+
+  await test.step('request body carried no upstream object', async () => {
+    expect(bodies).toHaveLength(1);
+    expect(bodies[0]).not.toHaveProperty('upstream');
+    expect(bodies[0]).toHaveProperty(['plugins', pluginName]);
+  });
+
+  await test.step('Admin API route is still plugins-only', async () => {
+    const route = (await getRouteReq(e2eReq, routeId))
+      .value as APISIXType['Route'];
+    expect(route.uri).toBe(routeUri);
+    expect(route.plugins).toHaveProperty(pluginName);
+    expect(route.upstream).toBeUndefined();
+  });
+});
diff --git a/src/components/form-slice/FormPartUpstream/util.test.ts 
b/src/components/form-slice/FormPartUpstream/util.test.ts
new file mode 100644
index 000000000..af592435a
--- /dev/null
+++ b/src/components/form-slice/FormPartUpstream/util.test.ts
@@ -0,0 +1,270 @@
+/**
+ * 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 { describe, expect, it } from 'vitest';
+
+import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
+import { produceStreamRoute } from 
'@/components/form-slice/FormPartStreamRoute/util';
+import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
+import { pipeProduce } from '@/utils/producer';
+
+import { isUntouchedInlineUpstream, produceRmEmptyUpstreamFields } from 
'./util';
+
+// Regression for apache/apisix-dashboard#3395.
+//
+// The inline upstream section of the route / service / stream-route forms is
+// always rendered, and its Select / Switch / JSON widgets register a value
+// the moment they mount: scheme=http, type=roundrobin, hash_on=vars,
+// pass_host=pass, tls.verify=false, discovery_args={}. react-hook-form keeps
+// those in the submit values, so a form whose upstream section the user
+// never opened still submitted `upstream: { scheme, type, hash_on, ... }` —
+// an upstream with neither nodes nor discovery, which the Admin API rejects:
+//   property "upstream" validation failed: value should match only one
+//   schema, but matches none
+// A plugins-only route (the public-api example) could not be created, and a
+// route created that way through the Admin API could not be edit-saved.
+//
+// `vite dev` and the NODE_ENV=test e2e build never showed this: under React
+// StrictMode the double-invoked controller effects run the shouldUnregister
+// cleanup, which wipes the mount-time values again. Only a production build
+// (the one shipped inside APISIX) sends them.
+
+/** exactly what react-hook-form holds for a never-opened upstream section */
+const untouchedInlineUpstream = () => ({
+  name: '',
+  desc: '',
+  labels: {},
+  service_name: '',
+  discovery_type: '',
+  discovery_args: {},
+  nodes: undefined,
+  scheme: 'http',
+  type: 'roundrobin',
+  hash_on: 'vars',
+  key: '',
+  pass_host: 'pass',
+  upstream_host: '',
+  retries: undefined,
+  retry_timeout: undefined,
+  timeout: { connect: undefined, send: undefined, read: undefined },
+  keepalive_pool: {
+    size: undefined,
+    idle_timeout: undefined,
+    requests: undefined,
+  },
+  tls: { verify: false, client_cert: '', client_key: '', client_cert_id: '' },
+});
+
+/** the whole Add Route form, submitted with only name, uri and a plugin */
+const pluginsOnlyRouteForm = () => ({
+  name: 'metrics',
+  desc: '',
+  labels: {},
+  status: 1,
+  priority: 0,
+  methods: [],
+  enable_websocket: false,
+  uri: '/apisix/prometheus/metrics',
+  uris: [],
+  host: '',
+  hosts: [],
+  remote_addr: '',
+  remote_addrs: [],
+  vars: '',
+  filter_func: '',
+  service_id: '',
+  timeout: { connect: undefined, send: undefined, read: undefined },
+  upstream_id: '',
+  upstream: untouchedInlineUpstream(),
+  plugin_config_id: '',
+  plugins: { 'public-api': {} },
+  __checksEnabled: false,
+  __checksPassiveEnabled: false,
+});
+
+describe('isUntouchedInlineUpstream', () => {
+  it('is true for the values a never-opened upstream section holds', () => {
+    expect(isUntouchedInlineUpstream(untouchedInlineUpstream())).toBe(true);
+  });
+
+  it('is true when the widgets left only their defaults after cleaning', () => 
{
+    expect(
+      isUntouchedInlineUpstream({
+        scheme: 'http',
+        type: 'roundrobin',
+        hash_on: 'vars',
+        pass_host: 'pass',
+        tls: { verify: false },
+        discovery_args: {},
+        nodes: [],
+      })
+    ).toBe(true);
+  });
+
+  it.each([
+    ['array nodes', { nodes: [{ host: 'a.local', port: 80, weight: 1 }] }],
+    ['object nodes', { nodes: { 'a.local:80': 1 } }],
+    // a node row the user added but has not filled in yet is still input —
+    // the Admin API must be the one to reject the empty host
+    ['an empty node row', { nodes: [{ host: '', port: 80, weight: 1 }] }],
+    ['discovery', { discovery_type: 'dns', service_name: 'svc' }],
+    ['a non-default scheme', { scheme: 'https' }],
+    ['a non-default balancer', { type: 'chash' }],
+    ['a non-default hash_on', { hash_on: 'header' }],
+    ['a non-default pass_host', { pass_host: 'node' }],
+    ['tls.verify switched on', { tls: { verify: true } }],
+    ['a client cert', { tls: { verify: false, client_cert: 'cert' } }],
+    ['a name', { name: 'my-upstream' }],
+    ['labels', { labels: { env: 'prod' } }],
+    ['a zero retries count', { retries: 0 }],
+    ['a timeout', { timeout: { connect: 3 } }],
+    ['a keepalive pool size', { keepalive_pool: { size: 320 } }],
+    ['health checks', { checks: { active: { type: 'http' } } }],
+    ['discovery args', { discovery_args: { namespace: 'x' } }],
+  ])('is false once the user supplied %s', (_, touched) => {
+    expect(
+      isUntouchedInlineUpstream({ ...untouchedInlineUpstream(), ...touched })
+    ).toBe(false);
+  });
+
+  it.each([undefined, null, '', 'nodes', 1, []])(
+    'is false for a non-object value (%s)',
+    (value) => {
+      expect(isUntouchedInlineUpstream(value)).toBe(false);
+    }
+  );
+
+  it('does not mutate its argument', () => {
+    const upstream = untouchedInlineUpstream();
+    const before = JSON.stringify(upstream);
+    isUntouchedInlineUpstream(upstream);
+    expect(JSON.stringify(upstream)).toBe(before);
+  });
+});
+
+describe('produceRmEmptyUpstreamFields', () => {
+  it('drops an inline upstream the user never touched', () => {
+    const out = produceRmEmptyUpstreamFields({
+      uri: '/x',
+      upstream: untouchedInlineUpstream(),
+    });
+    expect(out).not.toHaveProperty('upstream');
+    expect(out.uri).toBe('/x');
+  });
+
+  it('keeps an inline upstream with nodes, defaults included', () => {
+    const out = produceRmEmptyUpstreamFields({
+      upstream: {
+        ...untouchedInlineUpstream(),
+        nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+      },
+    });
+    expect(out.upstream).toMatchObject({
+      scheme: 'http',
+      type: 'roundrobin',
+      hash_on: 'vars',
+      pass_host: 'pass',
+      nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+    });
+  });
+
+  it('keeps an inline upstream that only has discovery', () => {
+    const out = produceRmEmptyUpstreamFields({
+      upstream: {
+        ...untouchedInlineUpstream(),
+        discovery_type: 'dns',
+        service_name: 'svc.local',
+      },
+    });
+    expect(out.upstream).toMatchObject({
+      discovery_type: 'dns',
+      service_name: 'svc.local',
+    });
+  });
+
+  it('keeps a touched but incomplete inline upstream for the Admin API to 
judge', () => {
+    // silently dropping it would discard what the user typed (#3417)
+    const out = produceRmEmptyUpstreamFields({
+      upstream: { ...untouchedInlineUpstream(), scheme: 'https' },
+    });
+    expect(out.upstream).toMatchObject({ scheme: 'https' });
+  });
+
+  it('leaves the root-level upstream form (upstreams page) alone', () => {
+    // the upstreams page submits these fields at the root, not under
+    // `upstream`; whether that is a valid upstream is the Admin API's call
+    const out = produceRmEmptyUpstreamFields(untouchedInlineUpstream());
+    expect(out).toMatchObject({ scheme: 'http', type: 'roundrobin' });
+  });
+});
+
+describe('submit pipelines with an untouched inline upstream', () => {
+  it('route add/edit: sends a plugins-only route without any upstream', () => {
+    const body = pipeProduce(
+      produceRmEmptyUpstreamFields,
+      produceRoute
+    )(pluginsOnlyRouteForm()) as Record<string, unknown>;
+    expect(body).not.toHaveProperty('upstream');
+    expect(body).not.toHaveProperty('upstream_id');
+    expect(body).not.toHaveProperty('service_id');
+    expect(body).toMatchObject({
+      name: 'metrics',
+      uri: '/apisix/prometheus/metrics',
+      plugins: { 'public-api': {} },
+    });
+  });
+
+  it('route: a filled inline upstream still goes through', () => {
+    const form = pluginsOnlyRouteForm();
+    form.upstream.nodes = [
+      { host: 'a.local', port: 80, weight: 1 },
+    ] as never;
+    const body = pipeProduce(
+      produceRmEmptyUpstreamFields,
+      produceRoute
+    )(form) as { upstream?: Record<string, unknown> };
+    expect(body.upstream).toMatchObject({
+      scheme: 'http',
+      nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+    });
+  });
+
+  it('service add/edit: a service with no upstream at all is sent without 
one', () => {
+    const body = pipeProduce(
+      produceRmUpstreamWhenHas('upstream_id'),
+      produceRmEmptyUpstreamFields
+    )({
+      name: 'svc',
+      upstream_id: '',
+      upstream: untouchedInlineUpstream(),
+      plugins: {},
+    }) as Record<string, unknown>;
+    expect(body).not.toHaveProperty('upstream');
+    expect(body.name).toBe('svc');
+  });
+
+  it('stream route add/edit: an untouched inline upstream is not sent', () => {
+    const body = produceStreamRoute({
+      server_addr: '127.0.0.1',
+      server_port: 9100,
+      upstream_id: '',
+      service_id: '',
+      upstream: untouchedInlineUpstream(),
+    } as never) as Record<string, unknown>;
+    expect(body).not.toHaveProperty('upstream');
+    expect(body.server_port).toBe(9100);
+  });
+});
diff --git a/src/components/form-slice/FormPartUpstream/util.ts 
b/src/components/form-slice/FormPartUpstream/util.ts
index 748118118..c7d353b88 100644
--- a/src/components/form-slice/FormPartUpstream/util.ts
+++ b/src/components/form-slice/FormPartUpstream/util.ts
@@ -14,10 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { produce } from 'immer';
-import { isNotEmpty } from 'rambdax';
+import { current, isDraft, produce } from 'immer';
+import { equals, isNotEmpty } from 'rambdax';
 
-import type { APISIXType } from '@/types/schema/apisix';
+import { APISIX, type APISIXType } from '@/types/schema/apisix';
+import { deepCleanEmptyKeys, rmDoubleUnderscoreKeys } from '@/utils/producer';
 
 import type { FormPartUpstreamType } from './schema';
 
@@ -63,6 +64,62 @@ const isAllUndefined = (obj: Record<string, unknown>) =>
     (v) => v === undefined || v === null || v === '' || Number.isNaN(v)
   );
 
+/**
+ * What the inline upstream widgets register the moment they mount, before
+ * the user has opened the section at all: FormItemScheme,
+ * FormSectionLoadbalancing and FormSectionPassHost pass the first schema
+ * option as their Select default, and the TLS `verify` Switch falls back to
+ * false. Every other widget mounts empty (`''`, `undefined`, `{}`, `[]`),
+ * which the cleaner already strips.
+ *
+ * Keep in step with those widgets: a new widget that registers a non-empty
+ * default must be listed here, or an untouched section is sent again.
+ */
+const INLINE_UPSTREAM_MOUNT_DEFAULTS: Record<string, unknown> = {
+  scheme: APISIX.UpstreamSchemeL7.options[0].value,
+  type: APISIX.UpstreamBalancer.options[0].value,
+  hash_on: APISIX.UpstreamHashOn.options[0].value,
+  pass_host: APISIX.UpstreamPassHost.options[0].value,
+  tls: { verify: false },
+};
+
+/**
+ * True when an inline `upstream` holds nothing but what its widgets
+ * registered on mount (#3395).
+ *
+ * The route / service / stream-route forms always render the inline upstream
+ * section, so react-hook-form submits `upstream: { scheme: 'http', type:
+ * 'roundrobin', hash_on: 'vars', pass_host: 'pass', tls: { verify: false } }`
+ * for a section the user never opened. The Admin API rejects that — an
+ * upstream needs nodes or discovery — so a plugins-only route (public-api)
+ * could not be created, and one created through the Admin API could not be
+ * edit-saved. Dropping such an upstream loses nothing: every value in it is
+ * the gateway default anyway.
+ *
+ * Anything the user did supply (a node row, even an empty one; a different
+ * scheme; a name; …) makes this false, and the upstream is sent as is for the
+ * Admin API to judge — silently discarding input is the #3417 failure mode.
+ */
+export const isUntouchedInlineUpstream = (upstream: unknown): boolean => {
+  if (!upstream || typeof upstream !== 'object' || Array.isArray(upstream)) {
+    return false;
+  }
+  // read-only: a plain snapshot of a (possibly nested) immer draft, cleaned
+  // into a fresh object so the caller's value is never touched
+  const plain = isDraft(upstream) ? current(upstream) : upstream;
+  const cleaned = deepCleanEmptyKeys(plain as Record<string, unknown>, {
+    cleanInPlace: false,
+  }) as Record<string, unknown>;
+  rmDoubleUnderscoreKeys(cleaned);
+  return Object.entries(cleaned).every(
+    ([key, value]) =>
+      Object.prototype.hasOwnProperty.call(
+        INLINE_UPSTREAM_MOUNT_DEFAULTS,
+        key
+      ) && equals(INLINE_UPSTREAM_MOUNT_DEFAULTS[key], value)
+  );
+};
+
 export const produceRmEmptyUpstreamFields = produce(
   (
     draft: {
@@ -73,7 +130,7 @@ export const produceRmEmptyUpstreamFields = produce(
         timeout?: Record<string, unknown>;
         keepalive_pool?: Record<string, unknown>;
         tls?: Record<string, unknown>;
-      };
+      } & Record<string, unknown>;
     } & Record<string, unknown>
   ) => {
     if (draft.timeout && isAllUndefined(draft.timeout)) {
@@ -110,6 +167,13 @@ export const produceRmEmptyUpstreamFields = produce(
       if (draft.upstream.tls && isAllUndefined(draft.upstream.tls)) {
         delete draft.upstream.tls;
       }
+
+      // the section was never opened: only the widgets' mount-time defaults
+      // are left, and the Admin API rejects an upstream with neither nodes
+      // nor discovery — send no upstream at all (#3395)
+      if (isUntouchedInlineUpstream(draft.upstream)) {
+        delete draft.upstream;
+      }
     }
   }
 );
diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx
index 4c6fb47c2..b59d12e67 100644
--- a/src/components/form/Editor.tsx
+++ b/src/components/form/Editor.tsx
@@ -163,7 +163,13 @@ export const FormItemEditor = <T extends FieldValues>(
           trigger(props.name);
         }}
         onMount={(editor) => {
-          if (process.env.NODE_ENV !== 'production') {
+          // test seam for the Playwright Monaco helpers. Keyed on the Vite
+          // mode, not NODE_ENV: the e2e image is a production build
+          // (`vite build --mode test`) so it runs the same React the
+          // release ships — a NODE_ENV=test build bundled development
+          // React, whose StrictMode double effects hid submit-body bugs
+          // (#3395)
+          if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
             window.__monacoEditor__ = editor;
           }
         }}
diff --git a/src/hooks/useEditCancelGuard.tsx b/src/hooks/useEditCancelGuard.tsx
index 4dfb96701..ecb7c5206 100644
--- a/src/hooks/useEditCancelGuard.tsx
+++ b/src/hooks/useEditCancelGuard.tsx
@@ -18,6 +18,7 @@ import { useCallback } from 'react';
 import type { FieldValues, UseFormReturn } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
+import { useFormBaseline } from '@/hooks/useFormBaseline';
 import { confirmDiscardChanges } from '@/hooks/useUnsavedChangesGuard';
 import { isFormDirty } from '@/utils/form-dirty';
 
@@ -33,24 +34,26 @@ import { isFormDirty } from '@/utils/form-dirty';
  * detail pages, and an ordinary refetch returns the same `data` reference
  * (react-query structural sharing), so the reset effect never re-runs.
  *
- * `isFormDirty` rather than react-hook-form's `isDirty` because the raw
- * flag reports pristine add pages as dirty; the two hooks share one
- * definition of "changed".
+ * `isFormDirty` against the post-mount baseline (`useFormBaseline`) rather
+ * than react-hook-form's `isDirty` or `formState.defaultValues`: both read
+ * the widgets' mount-time values as user edits (#3395); the two hooks
+ * share one definition of "changed".
  */
 export const useEditCancelGuard = <T extends FieldValues>(
   form: UseFormReturn<T>,
   onCancel: () => void
 ) => {
   const { t } = useTranslation();
+  const getBaseline = useFormBaseline(form);
   return useCallback(async () => {
     const discard = () => {
       form.reset();
       onCancel();
     };
-    if (!isFormDirty(form.formState.defaultValues, form.getValues())) {
+    if (!isFormDirty(getBaseline(), form.getValues())) {
       discard();
       return;
     }
     if (await confirmDiscardChanges(t)) discard();
-  }, [form, onCancel, t]);
+  }, [form, getBaseline, onCancel, t]);
 };
diff --git a/src/hooks/useFormBaseline.ts b/src/hooks/useFormBaseline.ts
new file mode 100644
index 000000000..6c1463b9e
--- /dev/null
+++ b/src/hooks/useFormBaseline.ts
@@ -0,0 +1,56 @@
+/**
+ * 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 { useCallback, useEffect, useRef } from 'react';
+import type { FieldValues, UseFormReturn } from 'react-hook-form';
+
+/**
+ * What the form held once its widgets had mounted — the reference point
+ * for "has the user changed anything".
+ *
+ * `formState.defaultValues` is not that point. Several widgets register a
+ * value of their own the moment they mount (a Select's first option, a
+ * Switch's `false`, the priority NumberInput's `0`), so on every add page
+ * `getValues()` differs from the defaults before the user has touched
+ * anything, and on every edit page it differs wherever the stored object
+ * omits a field. Comparing against the defaults flags such pristine forms
+ * as dirty — in production only: under StrictMode's double effects
+ * react-hook-form's `shouldUnregister` cleanup wipes the mount-time values
+ * again, which is why `vite dev` and the old development-React e2e image
+ * never showed it (#3395).
+ *
+ * The snapshot is taken after mount and again after every `reset(values)`:
+ * the detail pages reset once their data arrives, and `reset` replaces
+ * `formState.defaultValues`, which is what the effect keys on. By the time
+ * the effect runs the widgets have re-registered into the fresh values.
+ * It is a deep copy — react-hook-form mutates nested objects of the live
+ * values in place on change.
+ */
+export const useFormBaseline = <T extends FieldValues>(
+  form: UseFormReturn<T>
+) => {
+  const baselineRef = useRef<T | undefined>(undefined);
+  const { defaultValues } = form.formState;
+
+  useEffect(() => {
+    baselineRef.current = structuredClone(form.getValues());
+  }, [form, defaultValues]);
+
+  return useCallback(
+    (): T | undefined => baselineRef.current ?? (defaultValues as T | 
undefined),
+    [defaultValues]
+  );
+};
diff --git a/src/hooks/useUnsavedChangesGuard.tsx 
b/src/hooks/useUnsavedChangesGuard.tsx
index e7b5daf3b..da55216b9 100644
--- a/src/hooks/useUnsavedChangesGuard.tsx
+++ b/src/hooks/useUnsavedChangesGuard.tsx
@@ -21,6 +21,7 @@ import { useCallback, useRef } from 'react';
 import type { FieldValues, UseFormReturn } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
+import { useFormBaseline } from '@/hooks/useFormBaseline';
 import { isFormDirty } from '@/utils/form-dirty';
 
 type Translate = ReturnType<typeof useTranslation>['t'];
@@ -57,14 +58,16 @@ type UseUnsavedChangesGuardOptions = {
  * Block router navigations and tab close/reload while the form holds
  * unsaved work.
  *
- * Dirtiness comes from `isFormDirty`, not react-hook-form's `isDirty`:
- * the raw flag reports 5 of 11 add pages as dirty before the user types
- * anything, because their widgets normalize `undefined` to empty values
- * on mount.
+ * Dirtiness comes from `isFormDirty` against the post-mount baseline
+ * (`useFormBaseline`), not react-hook-form's `isDirty`: the raw flag
+ * reports 5 of 11 add pages as dirty before the user types anything,
+ * because their widgets normalize `undefined` to empty values on mount —
+ * and, in production, every add page, because Select / Switch / number
+ * widgets register a real default value on mount (#3395).
  *
  * The returned `bypass()` must be called before a navigation the form
  * itself initiates after a successful submit — at that point the form is
- * still dirty relative to its defaults, so the guard would otherwise
+ * still dirty relative to its baseline, so the guard would otherwise
  * block the page's own success redirect.
  */
 export const useUnsavedChangesGuard = <T extends FieldValues>(
@@ -73,11 +76,12 @@ export const useUnsavedChangesGuard = <T extends 
FieldValues>(
 ) => {
   const { t } = useTranslation();
   const bypassRef = useRef(false);
+  const getBaseline = useFormBaseline(form);
 
   const hasUnsavedChanges = useCallback(() => {
     if (bypassRef.current) return false;
-    return isFormDirty(form.formState.defaultValues, form.getValues());
-  }, [form]);
+    return isFormDirty(getBaseline(), form.getValues());
+  }, [form, getBaseline]);
 
   // Stable identity so useBlocker (which keys its effect on shouldBlockFn)
   // subscribes once rather than re-subscribing every render — which would

Reply via email to