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 631eadc25 fix: warn before discarding unsaved form changes (#3443)
631eadc25 is described below

commit 631eadc25dff721df77f2271e4e7319ff9f5ee14
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 14:34:51 2026 +0800

    fix: warn before discarding unsaved form changes (#3443)
---
 .../consumer_groups.crud-required-fields.spec.ts   |   9 +-
 .../regression/form.cancel-unsaved-warning.spec.ts |  10 +-
 .../regression/form.unsaved-changes-guard.spec.ts  | 218 +++++++++++++++++++++
 e2e/tests/ssls.crud-all-fields.spec.ts             |   9 +-
 src/components/form/Btn.tsx                        |  20 ++
 src/hooks/useEditCancelGuard.tsx                   |  67 +++----
 src/hooks/useUnsavedChangesGuard.tsx               | 101 ++++++++++
 src/routes/consumer_groups/add.tsx                 |  33 ++--
 src/routes/consumer_groups/detail.$id.tsx          |   2 +
 src/routes/consumers/add.tsx                       |  25 ++-
 .../consumers/detail.$username/credentials/add.tsx |  36 ++--
 .../detail.$username/credentials/detail.$id.tsx    |   2 +
 src/routes/consumers/detail.$username/index.tsx    |   2 +
 src/routes/global_rules/add.tsx                    |  35 ++--
 src/routes/global_rules/detail.$id.tsx             |   2 +
 src/routes/plugin_configs/add.tsx                  |  33 ++--
 src/routes/plugin_configs/detail.$id.tsx           |   2 +
 src/routes/protos/add.tsx                          |  27 ++-
 src/routes/protos/detail.$id.tsx                   |   2 +
 src/routes/routes/add.tsx                          |  35 ++--
 src/routes/routes/detail.$id.tsx                   |   2 +
 src/routes/secrets/add.tsx                         |  35 ++--
 src/routes/secrets/detail.$manager.$id.tsx         |   2 +
 src/routes/services/add.tsx                        |  25 ++-
 src/routes/services/detail.$id/index.tsx           |   2 +
 src/routes/services/detail.$id/routes/add.tsx      |   4 +
 .../services/detail.$id/stream_routes/add.tsx      |   4 +
 src/routes/ssls/add.tsx                            |  23 ++-
 src/routes/ssls/detail.$id.tsx                     |   2 +
 src/routes/stream_routes/add.tsx                   |  35 ++--
 src/routes/stream_routes/detail.$id.tsx            |   2 +
 src/routes/upstreams/add.tsx                       |  22 ++-
 src/routes/upstreams/detail.$id.tsx                |   2 +
 src/utils/form-dirty.test.ts                       | 204 +++++++++++++++++++
 src/utils/form-dirty.ts                            | 109 +++++++++++
 35 files changed, 960 insertions(+), 183 deletions(-)

diff --git a/e2e/tests/consumer_groups.crud-required-fields.spec.ts 
b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
index 5f0330909..c3352fd5e 100644
--- a/e2e/tests/consumer_groups.crud-required-fields.spec.ts
+++ b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
@@ -117,13 +117,10 @@ test('should CRUD Consumer Group with required fields', 
async ({ page }) => {
     const idField = page.getByRole('textbox', { name: 'ID', exact: true });
     await expect(idField).toBeDisabled();
 
-    // Cancel without making changes. The Edit→Cancel guard now always
-    // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
+    // Cancel without making changes. The Edit→Cancel guard only warns when
+    // the form actually holds unsaved edits (see 
src/hooks/useEditCancelGuard.tsx),
+    // so a pristine Cancel returns straight to view mode without a modal.
     await page.getByRole('button', { name: 'Cancel' }).click();
-    await page
-      .getByRole('dialog')
-      .getByRole('button', { name: 'Discard Changes' })
-      .click();
 
     // Verify we're back in detail view
     await consumerGroupsPom.isDetailPage(page);
diff --git a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts 
b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
index 1aed3f329..05a7a0d08 100644
--- a/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
+++ b/e2e/tests/regression/form.cancel-unsaved-warning.spec.ts
@@ -100,14 +100,16 @@ test('route detail Edit → Cancel with unsaved changes 
warns the user', async (
 test('route detail Edit → Cancel modal is dismissable (Cancel in modal stays 
in edit mode)', async ({
   page,
 }) => {
-  // Until the underlying form lifecycle is restructured (see the comment in
-  // useEditCancelGuard.tsx), the modal is shown on every Cancel click.
-  // This test pins the dismiss path: backing out of the warning modal must
-  // leave the user in edit mode without touching the form data.
+  // The modal is shown only when the form actually holds changes, so make
+  // one first. This test pins the dismiss path: backing out of the warning
+  // must leave the user in edit mode without touching the form data.
   await uiGoto(page, '/routes/detail/$id', { id: seededRouteId });
   await routesPom.isDetailPage(page);
 
   await page.getByRole('button', { name: 'Edit' }).click();
+  await page
+    .getByLabel('URI', { exact: true })
+    .fill('/regression/edit-cancel-dismiss');
   await page.getByRole('button', { name: 'Cancel', exact: true }).click();
 
   const modal = page
diff --git a/e2e/tests/regression/form.unsaved-changes-guard.spec.ts 
b/e2e/tests/regression/form.unsaved-changes-guard.spec.ts
new file mode 100644
index 000000000..74463a73f
--- /dev/null
+++ b/e2e/tests/regression/form.unsaved-changes-guard.spec.ts
@@ -0,0 +1,218 @@
+/**
+ * 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 for the dirty-form navigation guard: leaving a form with
+// unsaved edits — by sidebar link or the Cancel button — must confirm
+// before discarding. Conversely a form the user has not actually changed
+// must never interrogate them, which is the failure mode the raw
+// react-hook-form `isDirty` flag produces on add pages whose widgets
+// normalize `undefined` to empty values on mount.
+
+import { routesPom } from '@e2e/pom/routes';
+import { secretsPom } from '@e2e/pom/secrets';
+import { sslsPom } from '@e2e/pom/ssls';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect, type Page } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const unsavedModal = (page: Page) =>
+  page.getByRole('dialog').filter({ hasText: /unsaved/i });
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('leaving a dirty add form via the sidebar warns, and backing out keeps 
the input', async ({
+  page,
+}) => {
+  const name = randomId('reg-guard');
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await page.locator('input[name="name"]').fill(name);
+
+  await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+  const modal = unsavedModal(page);
+  await expect(modal).toBeVisible({ timeout: 5000 });
+
+  await modal.getByRole('button', { name: 'Cancel', exact: true }).click();
+  await expect(modal).toBeHidden();
+
+  await routesPom.isAddPage(page);
+  await expect(page.locator('input[name="name"]')).toHaveValue(name);
+});
+
+test('confirming the warning discards the edits and completes the navigation', 
async ({
+  page,
+}) => {
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await page.locator('input[name="name"]').fill(randomId('reg-guard'));
+  await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+  const modal = unsavedModal(page);
+  await expect(modal).toBeVisible({ timeout: 5000 });
+  await modal.getByRole('button', { name: /discard/i }).click();
+
+  await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('a successful submit navigates without a warning', async ({ page }) => {
+  // After a successful POST the form is still dirty relative to its
+  // defaults, so without `bypass()` the add page's own success redirect is
+  // blocked by the guard it just installed.
+  const name = randomId('reg-guard-submit');
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await page.locator('input[name="name"]').fill(name);
+  await page.getByLabel('URI', { exact: true }).fill(`/reg-guard/${name}`);
+
+  const upstreamSection = page.getByRole('group', {
+    name: 'Upstream',
+    exact: true,
+  });
+  await uiFillUpstreamRequiredFields(upstreamSection, {
+    name: `${name}-upstream`,
+    nodes: [
+      { host: 'guard-a.local', port: 80, weight: 100 },
+      { host: 'guard-b.local', port: 80, weight: 100 },
+    ],
+  });
+
+  await routesPom.getAddBtn(page).click();
+  await uiHasToastMsg(page, { hasText: 'Add Route Successfully' });
+
+  await expect(unsavedModal(page)).toBeHidden();
+  await routesPom.isDetailPage(page);
+});
+
+test('a pristine add page navigates away without interrogating the user', 
async ({
+  page,
+}) => {
+  // ssls/add is one of five add pages whose widgets normalize `undefined`
+  // to empty values on mount (certs: [], keys: []), so react-hook-form
+  // reports it dirty with zero user input. The guard must not.
+  await sslsPom.toAdd(page);
+  await sslsPom.isAddPage(page);
+
+  await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+  await expect(unsavedModal(page)).toBeHidden();
+  await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('a pristine nanoid add page (secrets) navigates away without 
interrogating the user', async ({
+  page,
+}) => {
+  // secrets/add seeds defaultValues.id with nanoid(); the id must be stable
+  // across renders or the pristine form reads dirty and the guard wrongly
+  // interrogates the user.
+  await secretsPom.toAdd(page);
+  await secretsPom.isAddPage(page);
+
+  await page.getByRole('link', { name: 'Services', exact: true }).click();
+
+  await expect(unsavedModal(page)).toBeHidden();
+  await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+});
+
+test('Cancel on a pristine add page returns to the list without a warning', 
async ({
+  page,
+}) => {
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+  await expect(unsavedModal(page)).toBeHidden();
+  await routesPom.isIndexPage(page);
+});
+
+test('Cancel on a dirty add page warns first', async ({ page }) => {
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+
+  await page.locator('input[name="name"]').fill(randomId('reg-guard-cancel'));
+  await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+  const modal = unsavedModal(page);
+  await expect(modal).toBeVisible({ timeout: 5000 });
+  await modal.getByRole('button', { name: /discard/i }).click();
+
+  await routesPom.isIndexPage(page);
+});
+
+test('Edit then Cancel with nothing changed does not interrogate the user', 
async ({
+  page,
+}) => {
+  const name = randomId('reg-guard-clean');
+  const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+    `/routes/${name}`,
+    {
+      name,
+      uri: `/reg-guard-clean/${name}`,
+      upstream: { type: 'roundrobin', nodes: { 'guard.local:80': 1 } },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+  await expect(unsavedModal(page)).toBeHidden();
+  await expect(page.getByLabel('URI', { exact: true })).toBeDisabled();
+});
+
+test('leaving a dirty detail form via the sidebar warns', async ({ page }) => {
+  const name = randomId('reg-guard-dirty');
+  const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+    `/routes/${name}`,
+    {
+      name,
+      uri: `/reg-guard-dirty/${name}`,
+      upstream: { type: 'roundrobin', nodes: { 'guard.local:80': 1 } },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  // The route detail page renders two "Description" fields (the route's own
+  // and the nested upstream's), so target the route-level one by name.
+  await page.locator('textarea[name="desc"]').fill('changed by the guard 
spec');
+
+  await page.getByRole('link', { name: 'Services', exact: true }).click();
+  await expect(unsavedModal(page)).toBeVisible({ timeout: 5000 });
+});
diff --git a/e2e/tests/ssls.crud-all-fields.spec.ts 
b/e2e/tests/ssls.crud-all-fields.spec.ts
index 1273d6d5d..83d95413b 100644
--- a/e2e/tests/ssls.crud-all-fields.spec.ts
+++ b/e2e/tests/ssls.crud-all-fields.spec.ts
@@ -149,13 +149,10 @@ test('should CRUD SSL with all fields', async ({ page }) 
=> {
     const cert1Field = page.getByRole('textbox', { name: 'Certificate 1' });
     await expect(cert1Field).toBeEnabled();
 
-    // Cancel without making changes. The Edit→Cancel guard now always
-    // confirms before discarding (see src/hooks/useEditCancelGuard.tsx).
+    // Cancel without making changes. The Edit→Cancel guard only warns when
+    // the form actually holds unsaved edits (see 
src/hooks/useEditCancelGuard.tsx),
+    // so a pristine Cancel returns straight to view mode without a modal.
     await page.getByRole('button', { name: 'Cancel' }).click();
-    await page
-      .getByRole('dialog')
-      .getByRole('button', { name: 'Discard Changes' })
-      .click();
 
     // Return to list page
     await sslsPom.getSSLNavBtn(page).click();
diff --git a/src/components/form/Btn.tsx b/src/components/form/Btn.tsx
index ac25ab41b..cbb8e341d 100644
--- a/src/components/form/Btn.tsx
+++ b/src/components/form/Btn.tsx
@@ -19,7 +19,11 @@ import {
   type ButtonProps,
   type PolymorphicComponentProps,
 } from '@mantine/core';
+import type { LinkProps } from '@tanstack/react-router';
 import { useFormContext, useFormState } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+import { RouteLinkBtn } from '@/components/Btn';
 
 export const FormSubmitBtn = (
   props: PolymorphicComponentProps<'button', ButtonProps>
@@ -28,3 +32,19 @@ export const FormSubmitBtn = (
   const { isSubmitting } = useFormState(form);
   return <Button type="submit" loading={isSubmitting} {...props} />;
 };
+
+export type FormCancelBtnProps = Pick<LinkProps, 'to' | 'params'>;
+
+/**
+ * Abandon a form and go back to its list page. Rendered as a router link
+ * so a dirty form routes it through the same navigation guard as any
+ * other navigation — no separate confirmation code path.
+ */
+export const FormCancelBtn = ({ to, params }: FormCancelBtnProps) => {
+  const { t } = useTranslation();
+  return (
+    <RouteLinkBtn variant="outline" to={to} params={params}>
+      {t('form.btn.cancel')}
+    </RouteLinkBtn>
+  );
+};
diff --git a/src/hooks/useEditCancelGuard.tsx b/src/hooks/useEditCancelGuard.tsx
index 1bed93c38..4dfb96701 100644
--- a/src/hooks/useEditCancelGuard.tsx
+++ b/src/hooks/useEditCancelGuard.tsx
@@ -14,56 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Text } from '@mantine/core';
-import { modals } from '@mantine/modals';
 import { useCallback } from 'react';
 import type { FieldValues, UseFormReturn } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
+import { confirmDiscardChanges } from '@/hooks/useUnsavedChangesGuard';
+import { isFormDirty } from '@/utils/form-dirty';
+
 /**
- * Guard the Edit-mode → Cancel handler with a confirmation modal so a
- * misclick can't silently throw away in-flight changes.
- *
- * Why the modal always shows (even on a clean form):
- *
- * Every Edit page in this app uses the pattern
- *
- *   useForm({ disabled: readOnly, defaultValues: ... })
- *   useEffect(() => form.reset(producedValues), [data, form])
- *
- * which has two failure modes for any "is the form actually dirty?" check:
- *
- *   1. Toggling `disabled` from true→false on Edit re-renders every
- *      controlled input and can fire spurious change events that flip
- *      react-hook-form's `isDirty` to true with no user input.
- *   2. The `form.reset(...)` inside a useEffect runs whenever the backing
- *      query refetches (tab focus, window focus, mutation success), which
- *      wipes `isDirty` back to false mid-session — so a legitimately
- *      edited form can transiently appear clean.
- *
- * Both make `isDirty` unreliable as a "skip the modal" signal. Until the
- * underlying form lifecycle is restructured (tracked as a follow-up
- * cleanup), we always show the modal. One extra click is a far better
- * failure mode than silently losing an edit.
+ * Guard the Edit-mode → Cancel handler so a misclick cannot throw away
+ * in-flight changes — but only when there are changes to throw away.
+ *
+ * This hook used to open the modal unconditionally, on the theory that
+ * `isDirty` could not be trusted under the `disabled`-toggling edit
+ * architecture: that toggling `disabled` fires spurious change events, and
+ * that the reset-on-refetch effect wipes dirtiness mid-session. Neither
+ * reproduces. Entering edit mode leaves the form clean on all twelve
+ * 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".
  */
 export const useEditCancelGuard = <T extends FieldValues>(
   form: UseFormReturn<T>,
   onCancel: () => void
 ) => {
   const { t } = useTranslation();
-  return useCallback(() => {
-    modals.openConfirmModal({
-      centered: true,
-      title: t('info.unsaved.title'),
-      children: <Text size="sm">{t('info.unsaved.content')}</Text>,
-      labels: {
-        confirm: t('info.unsaved.confirm'),
-        cancel: t('form.btn.cancel'),
-      },
-      onConfirm: () => {
-        form.reset();
-        onCancel();
-      },
-    });
+  return useCallback(async () => {
+    const discard = () => {
+      form.reset();
+      onCancel();
+    };
+    if (!isFormDirty(form.formState.defaultValues, form.getValues())) {
+      discard();
+      return;
+    }
+    if (await confirmDiscardChanges(t)) discard();
   }, [form, onCancel, t]);
 };
diff --git a/src/hooks/useUnsavedChangesGuard.tsx 
b/src/hooks/useUnsavedChangesGuard.tsx
new file mode 100644
index 000000000..e7b5daf3b
--- /dev/null
+++ b/src/hooks/useUnsavedChangesGuard.tsx
@@ -0,0 +1,101 @@
+/**
+ * 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 { Text } from '@mantine/core';
+import { modals } from '@mantine/modals';
+import { useBlocker } from '@tanstack/react-router';
+import { useCallback, useRef } from 'react';
+import type { FieldValues, UseFormReturn } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+import { isFormDirty } from '@/utils/form-dirty';
+
+type Translate = ReturnType<typeof useTranslation>['t'];
+
+/**
+ * Ask the user whether to throw away unsaved edits.
+ *
+ * Resolves true when they confirm. Escape and outside-clicks resolve
+ * false via onClose; Promise.resolve is idempotent, so the onConfirm path
+ * still wins when both fire.
+ */
+export const confirmDiscardChanges = (t: Translate) =>
+  new Promise<boolean>((resolve) => {
+    modals.openConfirmModal({
+      centered: true,
+      title: t('info.unsaved.title'),
+      children: <Text size="sm">{t('info.unsaved.content')}</Text>,
+      labels: {
+        confirm: t('info.unsaved.confirm'),
+        cancel: t('form.btn.cancel'),
+      },
+      onConfirm: () => resolve(true),
+      onCancel: () => resolve(false),
+      onClose: () => resolve(false),
+    });
+  });
+
+type UseUnsavedChangesGuardOptions = {
+  /** Skip the guard entirely — e.g. a detail page in read-only mode. */
+  disabled?: boolean;
+};
+
+/**
+ * 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.
+ *
+ * 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
+ * block the page's own success redirect.
+ */
+export const useUnsavedChangesGuard = <T extends FieldValues>(
+  form: UseFormReturn<T>,
+  options: UseUnsavedChangesGuardOptions = {}
+) => {
+  const { t } = useTranslation();
+  const bypassRef = useRef(false);
+
+  const hasUnsavedChanges = useCallback(() => {
+    if (bypassRef.current) return false;
+    return isFormDirty(form.formState.defaultValues, form.getValues());
+  }, [form]);
+
+  // Stable identity so useBlocker (which keys its effect on shouldBlockFn)
+  // subscribes once rather than re-subscribing every render — which would
+  // also re-run isFormDirty's structuredClone on each render.
+  const shouldBlockFn = useCallback(async () => {
+    if (!hasUnsavedChanges()) return false;
+    return !(await confirmDiscardChanges(t));
+  }, [hasUnsavedChanges, t]);
+
+  useBlocker({
+    disabled: options.disabled,
+    enableBeforeUnload: hasUnsavedChanges,
+    shouldBlockFn,
+  });
+
+  const bypass = useCallback(() => {
+    bypassRef.current = true;
+  }, []);
+
+  return { bypass };
+};
diff --git a/src/routes/consumer_groups/add.tsx 
b/src/routes/consumer_groups/add.tsx
index ed216f38d..57ff3b0fb 100644
--- a/src/routes/consumer_groups/add.tsx
+++ b/src/routes/consumer_groups/add.tsx
@@ -15,26 +15,41 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
 import { nanoid } from 'nanoid';
+import { useState } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putConsumerGroupReq } from '@/apis/consumer_groups';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartPluginConfig } from 
'@/components/form-slice/FormPartPluginConfig';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
 const ConsumerGroupAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
+  const [id] = useState(() => nanoid());
+
+  const form = useForm({
+    resolver: zodResolver(APISIX.ConsumerGroupPut),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+    defaultValues: {
+      id,
+    },
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
 
   const putConsumerGroup = useMutation({
     mutationFn: (d: APISIXType['ConsumerGroupPut']) =>
@@ -44,6 +59,7 @@ const ConsumerGroupAddForm = () => {
         message: t('info.add.success', { name: t('consumerGroups.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/consumer_groups/detail/$id',
         params: { id: response.data.value.id },
@@ -51,16 +67,6 @@ const ConsumerGroupAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.ConsumerGroupPut),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-    defaultValues: {
-      id: nanoid(),
-    },
-  });
-
   return (
     <FormProvider {...form}>
       <form
@@ -70,7 +76,10 @@ const ConsumerGroupAddForm = () => {
       >
         <FormSectionGeneral />
         <FormPartPluginConfig basicProps={{ showName: false }} />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/consumer_groups" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/consumer_groups/detail.$id.tsx 
b/src/routes/consumer_groups/detail.$id.tsx
index 8e9895622..ece24f68f 100644
--- a/src/routes/consumer_groups/detail.$id.tsx
+++ b/src/routes/consumer_groups/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_CONSUMER_GROUPS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -80,6 +81,7 @@ const ConsumerGroupDetailForm = (props: Props) => {
     form.reset(data.value);
   }, [form, data.value]);
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (!data) return <Skeleton height={200} />;
diff --git a/src/routes/consumers/add.tsx b/src/routes/consumers/add.tsx
index 6d3a33c64..dd305df8e 100644
--- a/src/routes/consumers/add.tsx
+++ b/src/routes/consumers/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,11 +23,12 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putConsumerReq } from '@/apis/consumers';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartConsumer } from '@/components/form-slice/FormPartConsumer';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -34,6 +36,14 @@ const ConsumerAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
 
+  const form = useForm({
+    resolver: zodResolver(APISIX.ConsumerPut),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const putConsumer = useMutation({
     mutationFn: (d: APISIXType['ConsumerPut']) => putConsumerReq(req, d),
     async onSuccess(_, res) {
@@ -41,6 +51,7 @@ const ConsumerAddForm = () => {
         message: t('info.add.success', { name: t('consumers.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/consumers/detail/$username',
         params: { username: res.username },
@@ -48,13 +59,6 @@ const ConsumerAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.ConsumerPut),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-  });
-
   return (
     <FormProvider {...form}>
       <form
@@ -63,7 +67,10 @@ const ConsumerAddForm = () => {
         )}
       >
         <FormPartConsumer readOnlyUsername={false} />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/consumers" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/consumers/detail.$username/credentials/add.tsx 
b/src/routes/consumers/detail.$username/credentials/add.tsx
index dd92a9dc6..b40acf448 100644
--- a/src/routes/consumers/detail.$username/credentials/add.tsx
+++ b/src/routes/consumers/detail.$username/credentials/add.tsx
@@ -15,20 +15,23 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useParams, useRouter } from '@tanstack/react-router';
 import { nanoid } from 'nanoid';
+import { useState } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putCredentialReq } from '@/apis/credentials';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartCredential } from 
'@/components/form-slice/FormPartCredential';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -38,6 +41,18 @@ const CredentialAddForm = () => {
   const { username } = useParams({
     from: '/consumers/detail/$username/credentials/add',
   });
+  const [id] = useState(() => nanoid());
+
+  const form = useForm({
+    resolver: zodResolver(APISIX.CredentialPut),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+    defaultValues: {
+      id,
+    },
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
 
   const putCredential = useMutation({
     mutationFn: (d: APISIXType['CredentialPut']) =>
@@ -49,6 +64,7 @@ const CredentialAddForm = () => {
         }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/consumers/detail/$username/credentials/detail/$id',
         params: { username, id: res.id },
@@ -56,22 +72,18 @@ const CredentialAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.CredentialPut),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-    defaultValues: {
-      id: nanoid(),
-    },
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => putCredential.mutateAsync(d))}>
         <FormSectionGeneral />
         <FormPartCredential />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn
+            to="/consumers/detail/$username/credentials"
+            params={{ username }}
+          />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx 
b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
index 2684840bc..89aafafd8 100644
--- a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
+++ b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_CREDENTIALS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -87,6 +88,7 @@ const CredentialDetailForm = (props: CredentialFormProps) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (isLoading) {
diff --git a/src/routes/consumers/detail.$username/index.tsx 
b/src/routes/consumers/detail.$username/index.tsx
index b0ae4bea7..44751eab4 100644
--- a/src/routes/consumers/detail.$username/index.tsx
+++ b/src/routes/consumers/detail.$username/index.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_CONSUMERS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -81,6 +82,7 @@ const ConsumerDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (isLoading) {
diff --git a/src/routes/global_rules/add.tsx b/src/routes/global_rules/add.tsx
index 80a787341..b8f2d2a1a 100644
--- a/src/routes/global_rules/add.tsx
+++ b/src/routes/global_rules/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import {
@@ -22,22 +23,37 @@ import {
   useRouter as useReactRouter,
 } from '@tanstack/react-router';
 import { nanoid } from 'nanoid';
+import { useState } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putGlobalRuleReq } from '@/apis/global_rules';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartGlobalRules } from 
'@/components/form-slice/FormPartGlobalRules';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import type { APISIXType } from '@/types/schema/apisix';
 import { APISIX } from '@/types/schema/apisix';
 
 const GlobalRuleAddForm = () => {
   const { t } = useTranslation();
   const router = useReactRouter();
+  const [id] = useState(() => nanoid());
+
+  const form = useForm({
+    resolver: zodResolver(APISIX.GlobalRulePut),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    defaultValues: {
+      plugins: {},
+      id,
+    },
+    mode: 'onChange',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
 
   const putGlobalRule = useMutation({
     mutationFn: (d: APISIXType['GlobalRulePut']) => putGlobalRuleReq(req, d),
@@ -47,6 +63,7 @@ const GlobalRuleAddForm = () => {
         message: t('info.add.success', { name: t('globalRules.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/global_rules/detail/$id',
         params: { id: res.data.value.id },
@@ -54,23 +71,15 @@ const GlobalRuleAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.GlobalRulePut),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    defaultValues: {
-      plugins: {},
-      id: nanoid(),
-    },
-    mode: 'onChange',
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => putGlobalRule.mutateAsync(d))}>
         <FormSectionGeneral />
         <FormPartGlobalRules />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/global_rules" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/global_rules/detail.$id.tsx 
b/src/routes/global_rules/detail.$id.tsx
index 29e012242..5c6e65f2c 100644
--- a/src/routes/global_rules/detail.$id.tsx
+++ b/src/routes/global_rules/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_GLOBAL_RULES } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 
 type Props = {
@@ -78,6 +79,7 @@ const GlobalRuleDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   return (
diff --git a/src/routes/plugin_configs/add.tsx 
b/src/routes/plugin_configs/add.tsx
index ee9ef397b..3ff25a695 100644
--- a/src/routes/plugin_configs/add.tsx
+++ b/src/routes/plugin_configs/add.tsx
@@ -15,26 +15,41 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
 import { nanoid } from 'nanoid';
+import { useState } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putPluginConfigReq } from '@/apis/plugin_configs';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartPluginConfig } from 
'@/components/form-slice/FormPartPluginConfig';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
 const PluginConfigAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
+  const [id] = useState(() => nanoid());
+
+  const form = useForm({
+    resolver: zodResolver(APISIX.PluginConfigPut),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+    defaultValues: {
+      id,
+    },
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
 
   const putPluginConfig = useMutation({
     mutationFn: (d: APISIXType['PluginConfigPut']) =>
@@ -44,6 +59,7 @@ const PluginConfigAddForm = () => {
         message: t('info.add.success', { name: t('pluginConfigs.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/plugin_configs/detail/$id',
         params: { id: response.data.value.id },
@@ -51,16 +67,6 @@ const PluginConfigAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.PluginConfigPut),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-    defaultValues: {
-      id: nanoid(),
-    },
-  });
-
   return (
     <FormProvider {...form}>
       <form
@@ -70,7 +76,10 @@ const PluginConfigAddForm = () => {
       >
         <FormSectionGeneral />
         <FormPartPluginConfig />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/plugin_configs" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/plugin_configs/detail.$id.tsx 
b/src/routes/plugin_configs/detail.$id.tsx
index 6342c2899..4cf8304a6 100644
--- a/src/routes/plugin_configs/detail.$id.tsx
+++ b/src/routes/plugin_configs/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_PLUGIN_CONFIGS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -82,6 +83,7 @@ const PluginConfigDetailForm = (props: Props) => {
     form.reset(initialValue);
   }, [form, initialValue]);
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (!data) return <Skeleton height={200} />;
diff --git a/src/routes/protos/add.tsx b/src/routes/protos/add.tsx
index 798dac75f..9e5bd2054 100644
--- a/src/routes/protos/add.tsx
+++ b/src/routes/protos/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import {
@@ -25,10 +26,11 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { postProtoReq } from '@/apis/protos';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartProto } from '@/components/form-slice/FormPartProto';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import type { APISIXType } from '@/types/schema/apisix';
 import { APISIXProtos } from '@/types/schema/apisix/protos';
 
@@ -40,6 +42,15 @@ const ProtoAddForm = () => {
   const { t } = useTranslation();
   const router = useReactRouter();
 
+  const form = useForm({
+    resolver: zodResolver(APISIXProtos.ProtoPost),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    defaultValues,
+    mode: 'onChange',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postProto = useMutation({
     mutationFn: (d: APISIXType['ProtoPost']) => postProtoReq(req, d),
     async onSuccess() {
@@ -47,23 +58,19 @@ const ProtoAddForm = () => {
         message: t('info.add.success', { name: t('protos.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({ to: '/protos' });
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIXProtos.ProtoPost),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    defaultValues,
-    mode: 'onChange',
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => postProto.mutateAsync(d))}>
         <FormPartProto />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/protos" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/protos/detail.$id.tsx b/src/routes/protos/detail.$id.tsx
index c71de41d2..672593445 100644
--- a/src/routes/protos/detail.$id.tsx
+++ b/src/routes/protos/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_PROTOS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -82,6 +83,7 @@ const ProtoDetailForm = ({ id, readOnly, setReadOnly }: 
ProtoFormProps) => {
     }
   }, [protoData, form]);
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (isLoading) {
diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx
index 47d37a26b..c67d742b4 100644
--- a/src/routes/routes/add.tsx
+++ b/src/routes/routes/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
@@ -22,7 +23,11 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { postRouteReq } from '@/apis/routes';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import {
+  FormCancelBtn,
+  type FormCancelBtnProps,
+  FormSubmitBtn,
+} from '@/components/form/Btn';
 import { FormPartRoute } from '@/components/form-slice/FormPartRoute';
 import {
   RoutePostSchema,
@@ -33,18 +38,29 @@ import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUp
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import type { APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
 type Props = {
   navigate: (res: APISIXType['RespRouteDetail']) => Promise<void>;
   defaultValues?: Partial<RoutePostType>;
+  cancelLink: FormCancelBtnProps;
 };
 
 export const RouteAddForm = (props: Props) => {
-  const { navigate, defaultValues } = props;
+  const { navigate, defaultValues, cancelLink } = props;
   const { t } = useTranslation();
 
+  const form = useForm({
+    resolver: zodResolver(RoutePostSchema),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+    defaultValues,
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postRoute = useMutation({
     mutationFn: (d: RoutePostType) =>
       postRouteReq(
@@ -59,23 +75,19 @@ export const RouteAddForm = (props: Props) => {
         message: t('info.add.success', { name: t('routes.singular') }),
         color: 'green',
       });
+      bypass();
       await navigate(res);
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(RoutePostSchema),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-    defaultValues,
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => postRoute.mutateAsync(d))}>
         <FormPartRoute />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn {...cancelLink} />
+        </Group>
       </form>
     </FormProvider>
   );
@@ -95,6 +107,7 @@ function RouteComponent() {
               params: { id: res.data.value.id },
             })
           }
+          cancelLink={{ to: '/routes' }}
         />
       </FormTOCBox>
     </>
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index baf0916df..43e98bd03 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -52,6 +52,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_ROUTES } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -113,6 +114,7 @@ const RouteDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   return (
diff --git a/src/routes/secrets/add.tsx b/src/routes/secrets/add.tsx
index ee62710b1..1c86de958 100644
--- a/src/routes/secrets/add.tsx
+++ b/src/routes/secrets/add.tsx
@@ -15,27 +15,43 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
 import { nanoid } from 'nanoid';
+import { useState } from 'react';
 import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { putSecretReq } from '@/apis/secrets';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartSecret } from '@/components/form-slice/FormPartSecret';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import PageHeader from '@/components/page/PageHeader';
 import { queryClient } from '@/config/global';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
 const SecretAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
+  const [id] = useState(() => nanoid());
+
+  const form = useForm({
+    resolver: zodResolver(APISIX.Secret),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    defaultValues: {
+      id,
+      manager: APISIX.Secret.options[0].shape.manager.value,
+    },
+    mode: 'all',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
 
   const putSecret = useMutation({
     mutationFn: (d: APISIXType['Secret']) =>
@@ -47,29 +63,22 @@ const SecretAddForm = () => {
       });
       // Invalidate secrets list query to refetch fresh data
       await queryClient.invalidateQueries({ queryKey: ['secrets'] });
+      bypass();
       await router.navigate({
         to: '/secrets',
       });
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(APISIX.Secret),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    defaultValues: {
-      id: nanoid(),
-      manager: APISIX.Secret.options[0].shape.manager.value,
-    },
-    mode: 'all',
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => putSecret.mutateAsync(d))}>
         <FormSectionGeneral />
         <FormPartSecret />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/secrets" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/secrets/detail.$manager.$id.tsx 
b/src/routes/secrets/detail.$manager.$id.tsx
index 69b4ead46..d5771d30f 100644
--- a/src/routes/secrets/detail.$manager.$id.tsx
+++ b/src/routes/secrets/detail.$manager.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_SECRETS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -88,6 +89,7 @@ const SecretDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (isLoading) {
diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx
index 28498edad..a00e91957 100644
--- a/src/routes/services/add.tsx
+++ b/src/routes/services/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,13 +23,14 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { postServiceReq, type ServicePostType } from '@/apis/services';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, 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';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
 import { pipeProduce } from '@/utils/producer';
 
@@ -36,6 +38,14 @@ const ServiceAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
 
+  const form = useForm({
+    resolver: zodResolver(ServicePostSchema),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postService = useMutation({
     mutationFn: (d: ServicePostType) =>
       postServiceReq(
@@ -47,6 +57,7 @@ const ServiceAddForm = () => {
         message: t('info.add.success', { name: t('services.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/services/detail/$id',
         params: { id: res.data.value.id },
@@ -54,18 +65,14 @@ const ServiceAddForm = () => {
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(ServicePostSchema),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d as 
ServicePostType))}>
         <FormPartService />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/services" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/services/detail.$id/index.tsx 
b/src/routes/services/detail.$id/index.tsx
index a0a5a70f9..b6f879f90 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -43,6 +43,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_SERVICES } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
 import { pipeProduce } from '@/utils/producer';
@@ -98,6 +99,7 @@ const ServiceDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   return (
diff --git a/src/routes/services/detail.$id/routes/add.tsx 
b/src/routes/services/detail.$id/routes/add.tsx
index 7b383a2a8..45aa7b9e3 100644
--- a/src/routes/services/detail.$id/routes/add.tsx
+++ b/src/routes/services/detail.$id/routes/add.tsx
@@ -44,6 +44,10 @@ function RouteComponent() {
           defaultValues={{
             service_id: id,
           }}
+          cancelLink={{
+            to: '/services/detail/$id/routes',
+            params: { id },
+          }}
         />
       </FormTOCBox>
     </CommonFormContext.Provider>
diff --git a/src/routes/services/detail.$id/stream_routes/add.tsx 
b/src/routes/services/detail.$id/stream_routes/add.tsx
index b062eb421..2c088992d 100644
--- a/src/routes/services/detail.$id/stream_routes/add.tsx
+++ b/src/routes/services/detail.$id/stream_routes/add.tsx
@@ -47,6 +47,10 @@ function RouteComponent() {
           defaultValues={{
             service_id: id,
           }}
+          cancelLink={{
+            to: '/services/detail/$id/stream_routes',
+            params: { id },
+          }}
         />
       </FormTOCBox>
     </CommonFormContext.Provider>
diff --git a/src/routes/ssls/add.tsx b/src/routes/ssls/add.tsx
index 7cbc1c0b0..f72b66d71 100644
--- a/src/routes/ssls/add.tsx
+++ b/src/routes/ssls/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -22,7 +23,7 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { postSSLReq } from '@/apis/ssls';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartSSL } from '@/components/form-slice/FormPartSSL';
 import {
   SSLPostSchema,
@@ -32,11 +33,19 @@ import { FormTOCBox } from 
'@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { queryClient } from '@/config/global';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { pipeProduce } from '@/utils/producer';
 
 const SSLAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
+  const form = useForm({
+    resolver: zodResolver(SSLPostSchema),
+    shouldUnregister: true,
+    mode: 'all',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postSSL = useMutation({
     mutationFn: (d: SSLPostType) => postSSLReq(req, pipeProduce()(d)),
     async onSuccess() {
@@ -46,23 +55,21 @@ const SSLAddForm = () => {
       });
       // Invalidate SSLs list query to refetch fresh data
       await queryClient.invalidateQueries({ queryKey: ['ssls'] });
+      bypass();
       await router.navigate({
         to: '/ssls',
       });
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(SSLPostSchema),
-    shouldUnregister: true,
-    mode: 'all',
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => postSSL.mutateAsync(d))}>
         <FormPartSSL />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/ssls" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx
index 2f4d996aa..c7a1b7e98 100644
--- a/src/routes/ssls/detail.$id.tsx
+++ b/src/routes/ssls/detail.$id.tsx
@@ -44,6 +44,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_SSLS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { pipeProduce } from '@/utils/producer';
 
 type Props = {
@@ -93,6 +94,7 @@ const SSLDetailForm = (props: Props & { id: string }) => {
     }
   }, [sslData, form, isLoading]);
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   if (isLoading) {
diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx
index 6364ad03a..05cabbcad 100644
--- a/src/routes/stream_routes/add.tsx
+++ b/src/routes/stream_routes/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
@@ -22,7 +23,11 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
 import { postStreamRouteReq } from '@/apis/stream_routes';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import {
+  FormCancelBtn,
+  type FormCancelBtnProps,
+  FormSubmitBtn,
+} from '@/components/form/Btn';
 import { FormPartStreamRoute } from 
'@/components/form-slice/FormPartStreamRoute';
 import {
   StreamRoutePostSchema,
@@ -33,17 +38,28 @@ import { FormTOCBox } from 
'@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { StreamRoutesErrorComponent } from 
'@/components/page-slice/stream_routes/ErrorComponent';
 import { req } from '@/config/req';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import type { APISIXType } from '@/types/schema/apisix';
 
 type Props = {
   navigate: (res: APISIXType['RespStreamRouteDetail']) => Promise<void>;
   defaultValues?: Partial<StreamRoutePostType>;
+  cancelLink: FormCancelBtnProps;
 };
 
 export const StreamRouteAddForm = (props: Props) => {
-  const { navigate, defaultValues } = props;
+  const { navigate, defaultValues, cancelLink } = props;
   const { t } = useTranslation();
 
+  const form = useForm({
+    resolver: zodResolver(StreamRoutePostSchema),
+    shouldUnregister: true,
+    shouldFocusError: true,
+    mode: 'all',
+    defaultValues,
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postStreamRoute = useMutation({
     mutationFn: (d: StreamRoutePostType) =>
       postStreamRouteReq(req, produceStreamRoute(d)),
@@ -52,23 +68,19 @@ export const StreamRouteAddForm = (props: Props) => {
         message: t('info.add.success', { name: t('streamRoutes.singular') }),
         color: 'green',
       });
+      bypass();
       await navigate(res);
     },
   });
 
-  const form = useForm({
-    resolver: zodResolver(StreamRoutePostSchema),
-    shouldUnregister: true,
-    shouldFocusError: true,
-    mode: 'all',
-    defaultValues,
-  });
-
   return (
     <FormProvider {...form}>
       <form onSubmit={form.handleSubmit((d) => 
postStreamRoute.mutateAsync(d))}>
         <FormPartStreamRoute />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn {...cancelLink} />
+        </Group>
       </form>
     </FormProvider>
   );
@@ -90,6 +102,7 @@ function RouteComponent() {
               params: { id: res.data.value.id },
             })
           }
+          cancelLink={{ to: '/stream_routes' }}
         />
       </FormTOCBox>
     </>
diff --git a/src/routes/stream_routes/detail.$id.tsx 
b/src/routes/stream_routes/detail.$id.tsx
index 3b66a7618..16d2e5f4a 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -42,6 +42,7 @@ import { StreamRoutesErrorComponent } from 
'@/components/page-slice/stream_route
 import { API_STREAM_ROUTES } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { APISIX, type APISIXType } from '@/types/schema/apisix';
 
 type Props = {
@@ -90,6 +91,7 @@ const StreamRouteDetailForm = (props: Props) => {
     },
   });
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   return (
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index b03105720..7024630bd 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Group } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
 import { useMutation } from '@tanstack/react-query';
 import { createFileRoute, useRouter } from '@tanstack/react-router';
@@ -23,13 +24,14 @@ import { useTranslation } from 'react-i18next';
 import type { z } from 'zod';
 
 import { postUpstreamReq } from '@/apis/upstreams';
-import { FormSubmitBtn } from '@/components/form/Btn';
+import { FormCancelBtn, 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';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import { pipeProduce } from '@/utils/producer';
 
 const PostUpstreamSchema = FormPartUpstreamSchema.omit({
@@ -41,6 +43,13 @@ type PostUpstreamType = z.infer<typeof PostUpstreamSchema>;
 const UpstreamAddForm = () => {
   const { t } = useTranslation();
   const router = useRouter();
+  const form = useForm({
+    resolver: zodResolver(PostUpstreamSchema),
+    shouldUnregister: true,
+    mode: 'all',
+  });
+  const { bypass } = useUnsavedChangesGuard(form);
+
   const postUpstream = useMutation({
     mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, 
pipeProduce(produceRmEmptyUpstreamFields)(d) as PostUpstreamType),
     async onSuccess(data) {
@@ -48,17 +57,13 @@ const UpstreamAddForm = () => {
         message: t('info.add.success', { name: t('upstreams.singular') }),
         color: 'green',
       });
+      bypass();
       await router.navigate({
         to: '/upstreams/detail/$id',
         params: { id: data.data.value.id },
       });
     },
   });
-  const form = useForm({
-    resolver: zodResolver(PostUpstreamSchema),
-    shouldUnregister: true,
-    mode: 'all',
-  });
 
   return (
     <FormProvider {...form}>
@@ -68,7 +73,10 @@ const UpstreamAddForm = () => {
         )}
       >
         <FormPartUpstream />
-        <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+        <Group>
+          <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
+          <FormCancelBtn to="/upstreams" />
+        </Group>
       </form>
     </FormProvider>
   );
diff --git a/src/routes/upstreams/detail.$id.tsx 
b/src/routes/upstreams/detail.$id.tsx
index b7ba2eb05..f4921d249 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -48,6 +48,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_UPSTREAMS } from '@/config/constant';
 import { req } from '@/config/req';
 import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
+import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
 import type { APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -111,6 +112,7 @@ const UpstreamDetailForm = (
     }
   }, [upstreamData, form]);
 
+  useUnsavedChangesGuard(form, { disabled: readOnly });
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));
 
   return (
diff --git a/src/utils/form-dirty.test.ts b/src/utils/form-dirty.test.ts
new file mode 100644
index 000000000..25e48c183
--- /dev/null
+++ b/src/utils/form-dirty.test.ts
@@ -0,0 +1,204 @@
+/**
+ * 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 { objToUpstreamNodes } from 
'@/components/form-slice/FormPartUpstream/nodes-conversion';
+
+import { isFormDirty, normalizeForCompare } from './form-dirty';
+
+// Fixtures are real snapshots captured from the running app: on these add
+// pages the form widgets normalize `undefined` into empty values on mount,
+// so react-hook-form reports `isDirty === true` before the user types
+// anything. A navigation guard driven by the raw flag would interrogate
+// the user on 5 of 11 add pages for no reason.
+
+describe('isFormDirty — pristine add pages must compare clean', () => {
+  it('ignores empty arrays introduced on mount (ssls/add)', () => {
+    const defaults = {};
+    const values = { certs: [], keys: [] };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('ignores empty timestamp strings introduced on mount (secrets/add)', () 
=> {
+    const defaults = { id: 'abc', manager: 'vault' };
+    const values = {
+      create_time: '',
+      update_time: '',
+      id: 'abc',
+      manager: 'vault',
+    };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('ignores an empty plugins object introduced on mount (global_rules/add)', 
() => {
+    const defaults = { id: 'abc' };
+    const values = {
+      create_time: '',
+      update_time: '',
+      id: 'abc',
+      plugins: {},
+    };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+});
+
+describe('isFormDirty — real edits must compare dirty', () => {
+  it('detects a typed field value', () => {
+    expect(isFormDirty({ desc: 'before' }, { desc: 'after' })).toBe(true);
+  });
+
+  it('detects a plugin added with an empty config', () => {
+    expect(
+      isFormDirty({ plugins: {} }, { plugins: { prometheus: {} } })
+    ).toBe(true);
+  });
+
+  it('detects a plugin config edited to an empty member', () => {
+    expect(
+      isFormDirty(
+        { plugins: { 'key-auth': { key: 'k' } } },
+        { plugins: { 'key-auth': { key: '' } } }
+      )
+    ).toBe(true);
+  });
+
+  it('detects a non-empty field cleared by the user', () => {
+    expect(isFormDirty({ desc: 'before' }, { desc: '' })).toBe(true);
+  });
+});
+
+describe('isFormDirty — fields that must never affect the verdict', () => {
+  it('ignores __-prefixed UI flags', () => {
+    expect(
+      isFormDirty(
+        { uri: '/a', __checksEnabled: false },
+        { uri: '/a', __checksEnabled: true }
+      )
+    ).toBe(false);
+  });
+
+  it('ignores create_time and update_time', () => {
+    expect(
+      isFormDirty(
+        { uri: '/a', create_time: 1, update_time: 1 },
+        { uri: '/a', create_time: 2, update_time: 2 }
+      )
+    ).toBe(false);
+  });
+
+  it('treats a missing value object as empty rather than throwing', () => {
+    expect(() => isFormDirty(undefined, { uri: '/a' })).not.toThrow();
+    expect(isFormDirty(undefined, { uri: '/a' })).toBe(true);
+    expect(isFormDirty(undefined, {})).toBe(false);
+  });
+});
+
+describe('normalizeForCompare', () => {
+  it('does not mutate its input', () => {
+    const input = { desc: '', plugins: { a: {} } };
+    normalizeForCompare(input);
+    expect(input).toEqual({ desc: '', plugins: { a: {} } });
+  });
+});
+
+// The Admin API stores upstream `nodes` as an object map (`{ "host:port":
+// weight }`); the nodes widget normalizes that into an array on mount
+// (`[{ host, port, weight, priority }]`). Fixtures below build the array
+// side with the real converter (`objToUpstreamNodes`) so they match its
+// output exactly rather than guessing field names/order.
+describe('isFormDirty — upstream nodes object-map vs array form', () => {
+  it('does not flag a pristine edit page nested under upstream.nodes', () => {
+    const nodes = { 'h1.local:80': 1 };
+    const defaults = { upstream: { type: 'roundrobin', nodes } };
+    const values = {
+      upstream: { type: 'roundrobin', nodes: objToUpstreamNodes(nodes) },
+    };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('does not flag a pristine edit page with top-level nodes 
(upstreams/add)', () => {
+    const nodes = { 'h1.local:80': 1 };
+    const defaults = { nodes };
+    const values = { nodes: objToUpstreamNodes(nodes) };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('does not flag a bracketed IPv6 node key', () => {
+    const nodes = { '[::1]:80': 1 };
+    const defaults = { upstream: { nodes } };
+    const values = { upstream: { nodes: objToUpstreamNodes(nodes) } };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('does not flag a port-less node key', () => {
+    const nodes = { 'h1.local': 1 };
+    const defaults = { upstream: { nodes } };
+    const values = { upstream: { nodes: objToUpstreamNodes(nodes) } };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+
+  it('still flags a genuinely edited node weight', () => {
+    const defaults = {
+      upstream: { nodes: { 'h1.local:80': 1, 'h2.local:80': 2 } },
+    };
+    const values = {
+      upstream: {
+        nodes: objToUpstreamNodes({ 'h1.local:80': 5, 'h2.local:80': 2 }),
+      },
+    };
+    expect(isFormDirty(defaults, values)).toBe(true);
+  });
+
+  it('still flags a node added by the user', () => {
+    const defaults = { upstream: { nodes: { 'h1.local:80': 1 } } };
+    const values = {
+      upstream: {
+        nodes: objToUpstreamNodes({
+          'h1.local:80': 1,
+          'h2.local:80': 2,
+        }),
+      },
+    };
+    expect(isFormDirty(defaults, values)).toBe(true);
+  });
+
+  it('still flags a node removed by the user', () => {
+    const defaults = {
+      upstream: { nodes: { 'h1.local:80': 1, 'h2.local:80': 2 } },
+    };
+    const values = {
+      upstream: { nodes: objToUpstreamNodes({ 'h1.local:80': 1 }) },
+    };
+    expect(isFormDirty(defaults, values)).toBe(true);
+  });
+
+  it('still flags a node host edited by the user', () => {
+    const defaults = { upstream: { nodes: { 'h1.local:80': 1 } } };
+    const values = {
+      upstream: { nodes: objToUpstreamNodes({ 'h2.local:80': 1 }) },
+    };
+    expect(isFormDirty(defaults, values)).toBe(true);
+  });
+
+  it('ignores node order in the array form', () => {
+    const a = { host: 'h1.local', port: 80, weight: 1 };
+    const b = { host: 'h2.local', port: 80, weight: 2 };
+    const defaults = { upstream: { nodes: [a, b] } };
+    const values = { upstream: { nodes: [b, a] } };
+    expect(isFormDirty(defaults, values)).toBe(false);
+  });
+});
diff --git a/src/utils/form-dirty.ts b/src/utils/form-dirty.ts
new file mode 100644
index 000000000..37341daac
--- /dev/null
+++ b/src/utils/form-dirty.ts
@@ -0,0 +1,109 @@
+/**
+ * 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 { equals } from 'rambdax';
+
+import { objToUpstreamNodes } from 
'@/components/form-slice/FormPartUpstream/nodes-conversion';
+import type { APISIXType } from '@/types/schema/apisix';
+
+import { deepCleanEmptyKeys, rmDoubleUnderscoreKeys } from './producer';
+
+/** Server-managed timestamps: never a user edit. */
+const IGNORED_KEYS = ['create_time', 'update_time'];
+
+/**
+ * Canonicalize upstream `nodes` so the object-map form the Admin API stores
+ * (`{ "host:port": weight }`) compares equal to the array form the nodes
+ * widget produces on mount (`[{ host, port, weight, priority }]`).
+ *
+ * Without this, every edit page whose upstream stores object-form nodes reads
+ * dirty the instant the widget mounts, with zero user input:
+ * `formState.defaultValues` keeps the object form (it is seeded straight from
+ * the producer, which never rewrites nodes) while `getValues()` returns the
+ * array form the widget normalized to. `priority` defaults to 0 on both sides
+ * — the widget invents 0, the object form cannot express it at all.
+ */
+const canonicalizeNodes = (nodes: unknown): unknown => {
+  const arr = Array.isArray(nodes)
+    ? nodes
+    : nodes && typeof nodes === 'object'
+      ? objToUpstreamNodes(nodes as APISIXType['UpstreamNodeObj'])
+      : null;
+  if (!arr) return nodes;
+  return arr
+    .map((node) =>
+      node && typeof node === 'object'
+        ? {
+            ...(node as Record<string, unknown>),
+            priority: (node as Record<string, unknown>).priority ?? 0,
+          }
+        : node
+    )
+    .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
+};
+
+/** Rewrite every `nodes` field in place to its canonical, comparable form. */
+const canonicalizeNodesDeep = (value: unknown): void => {
+  if (!value || typeof value !== 'object') return;
+  if (Array.isArray(value)) {
+    value.forEach(canonicalizeNodesDeep);
+    return;
+  }
+  const obj = value as Record<string, unknown>;
+  if ('nodes' in obj) obj.nodes = canonicalizeNodes(obj.nodes);
+  Object.values(obj).forEach(canonicalizeNodesDeep);
+};
+
+/**
+ * Reduce a form value to the shape that decides whether the user has
+ * unsaved work.
+ *
+ * Add pages seed `useForm` with little or nothing, and the widgets
+ * normalize `undefined` into `""` / `[]` / `{}` as they mount. Comparing
+ * raw values — or trusting react-hook-form's `isDirty`, which is computed
+ * from the same raw values — reports 5 of 11 add pages as dirty before the
+ * user has typed anything (measured: ssls, secrets, global_rules,
+ * plugin_configs, consumer_groups).
+ *
+ * The `plugins` subtree is deliberately NOT cleaned: an added plugin whose
+ * config is still empty is a real edit, and cleaning would erase the only
+ * evidence of it. Normalizing `undefined` to `{}` keeps the pristine
+ * global_rules/add case (`plugins: {}` on one side, absent on the other)
+ * from registering as a change.
+ *
+ * `deepCleanEmptyKeys` (fast-clean) already leaves `false` and `0` alone by
+ * default — only `undefined`, `''`, `NaN`, `{}` and `[]` are stripped — so
+ * no extra option is needed to keep booleans like `__checksEnabled` (itself
+ * removed a line below by `rmDoubleUnderscoreKeys`, but any other boolean
+ * field must survive the clean untouched).
+ */
+export const normalizeForCompare = (
+  value: unknown
+): Record<string, unknown> => {
+  if (!value || typeof value !== 'object') return { plugins: {} };
+  const copy = structuredClone(value) as Record<string, unknown>;
+  const plugins = copy.plugins;
+  delete copy.plugins;
+  IGNORED_KEYS.forEach((key) => delete copy[key]);
+  canonicalizeNodesDeep(copy);
+  rmDoubleUnderscoreKeys(copy);
+  deepCleanEmptyKeys(copy);
+  return { ...copy, plugins: plugins ?? {} };
+};
+
+/** True when the form holds work the user has not saved. */
+export const isFormDirty = (defaults: unknown, values: unknown): boolean =>
+  !equals(normalizeForCompare(defaults), normalizeForCompare(values));

Reply via email to