lxbme opened a new pull request, #3443:
URL: https://github.com/apache/apisix-dashboard/pull/3443

   Please answer these questions before submitting a pull request, **or your PR 
will get closed**.
   
   **Why submit this pull request?**
   
   - [x] Bugfix
   - [x] New feature provided
   - [x] Improve performance
   
   **What changes will this PR take into?**
   
   This addresses the "Dirty-form navigation is fully unguarded" item of the 
frontend review in #3417. Three related defects travelled together in that one 
checkbox, and this PR fixes all three:
   
   1. **Nothing guarded navigation away from a form with unsaved edits.** There 
was not a single `useBlocker` / `beforeunload` in the app: a sidebar click, 
browser Back, tab close, or reload silently discarded in-flight edits.
   2. **The 14 "add" pages had no Cancel button at all** — navigating away (the 
unguarded path above) was the *only* way to abandon a half-filled form.
   3. **The Edit → Cancel confirmation interrogated the user unconditionally**, 
even on a form they had not changed. Its own comment claimed react-hook-form's 
`isDirty` could not be trusted under the `disabled`-toggling edit architecture.
   
   ### What the PR adds
   
   - **`src/utils/form-dirty.ts`** — a pure `isFormDirty(defaults, values)` 
that normalises both sides before comparing (drops UI-only `__`-prefixed flags, 
`create_time`/`update_time`, and empty `""` / `[]` / `{}` values; keeps plugin 
configs verbatim so an added-but-empty plugin still counts as an edit). This is 
needed because the raw `isDirty` flag reports several add pages as dirty before 
the user types anything — their widgets normalise `undefined` to empty values 
on mount.
   - **`src/hooks/useUnsavedChangesGuard.tsx`** — wires that check to TanStack 
Router's `useBlocker` (a confirm modal on in-app navigation) and 
`enableBeforeUnload` (the browser's native prompt on reload / tab close). A 
`bypass()` handle lets a page's own post-submit redirect through.
   - **`FormCancelBtn`** (in `src/components/form/Btn.tsx`) — a Cancel button 
rendered as a router link, so a dirty form routes Cancel through the same guard 
with no separate code path. Added to all 14 add routes; each returns to its 
list page (nested service sub-routes return to the correct sub-list).
   - **12 detail pages** mount the guard with `disabled: readOnly`, and 
**`useEditCancelGuard`** now consults `isFormDirty` — a clean form cancels 
without a modal, a dirty one still confirms. The two hooks share one modal 
definition and one definition of "changed".
   
   ### Verifying the claims before fixing
   
   The old `useEditCancelGuard` comment asserted `isDirty` was untrustworthy 
(spurious change events from the `disabled` toggle; the reset-on-refetch effect 
wiping dirtiness). Measured against all 12 detail pages, neither reproduces: 
entering edit mode leaves the form clean, and an ordinary refetch returns the 
same `data` reference (react-query structural sharing) so the reset effect 
never re-runs. That is why the Cancel modal can safely become conditional 
without the larger "mount the form only in edit mode" restructure the review 
suggested (left out of scope here).
   
   ### Two latent bugs surfaced while building this
   
   - **Object-form upstream nodes read spurious-dirty.** A detail page whose 
upstream stores nodes as the Admin API's object map (`{ "host:port": weight }`) 
compared unequal to the array form the nodes widget produces on mount, so any 
edit page with an upstream looked dirty with zero input. `form-dirty.ts` 
canonicalises nodes on both sides before comparing (unit-tested for 
object-map/array equality, IPv6, port-less, and genuine node edits).
   - **`nanoid()`-seeded ids were unstable across renders.** Five add pages 
seeded `defaultValues: { id: nanoid() }` with `nanoid()` called in the render 
body, so `formState.defaultValues.id` and `getValues().id` held two different 
ids and the pristine form read dirty. Fixed by generating the id once via 
`useState(() => nanoid())`. This only stabilises an already-present default — 
the submitted payload is unchanged.
   
   ### Performance
   
   The blocker's `shouldBlockFn` is memoised so `useBlocker` subscribes once 
rather than re-subscribing (and re-running the dirty check) on every render.
   
   **Related issues**
   
   Part of #3417
   
   **Checklist:**
   
   - [x] Did you explain what problem does this PR solve? Or what new features 
have been added?
   - [x] Have you added corresponding test cases?
   - [x] Have you modified the corresponding document?
   - [x] Is this PR backward compatible? If it is not backward compatible, 
please discuss on the mailing list first
   
   Tests: unit coverage for the dirty check (empty-value normalisation, 
plugin-config edits, node canonicalization) in `src/utils/form-dirty.test.ts`; 
e2e coverage in `e2e/tests/regression/form.unsaved-changes-guard.spec.ts` 
(dirty add/detail navigation warns; pristine add pages — including a 
`nanoid`-seeded one — navigate without a prompt; Cancel button behaviour; 
successful submit is not blocked). The `beforeunload` path has no e2e assertion 
because headless Chrome auto-dismisses the native dialog; it was verified 
manually (pristine page does not arm it, an edited page does). No documentation 
changes are needed — this is behavioural.
   
   One question for reviewers: the reused `info.unsaved.content` copy reads 
"You have unsaved changes. Are you sure you want to close?", which is slightly 
stilted for a navigation rather than a modal close. Rewording it touches the 
de/es/tr translations, so I left it as-is rather than changing wording I cannot 
verify — happy to update if you'd prefer.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to