From 5a135d2adb1b4929aeb8ed45e0e2c1cc17694bba Mon Sep 17 00:00:00 2001 From: Hamed Date: Sat, 24 Oct 2026 06:24:19 +0330 Subject: [PATCH] fix(inbounds): preserve formatting in the advanced JSON editor (#6857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the inbound form's Advanced → All editor, pressing Enter or adding any whitespace while the JSON stayed valid made the panel replace the operator's text with its own formatting, dropping the new line and moving the caret. Edits that only change formatting are now left alone, while changes made elsewhere in the form still reach the editor, a tag change patches only the tag, and an edit the form rejects (a non-numeric port, a removed section) is replaced by what the form will actually save, so the editor never shows JSON the panel will not submit. --- frontend/src/components/form/JsonEditor.tsx | 12 +- .../pages/inbounds/form/advanced-editors.tsx | 68 ++++++++-- .../src/test/advanced-all-editor.test.tsx | 126 ++++++++++++++++++ frontend/src/test/json-editor-caret.test.tsx | 35 +++++ 4 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 frontend/src/test/advanced-all-editor.test.tsx create mode 100644 frontend/src/test/json-editor-caret.test.tsx diff --git a/frontend/src/components/form/JsonEditor.tsx b/frontend/src/components/form/JsonEditor.tsx index f7372730e..f820354e4 100644 --- a/frontend/src/components/form/JsonEditor.tsx +++ b/frontend/src/components/form/JsonEditor.tsx @@ -157,8 +157,18 @@ const JsonEditor = forwardRef(function JsonEd if (!view) return; const current = view.state.doc.toString(); if (value === current) return; + let from = 0; + while (from < current.length && from < value.length && current[from] === value[from]) { + from += 1; + } + let oldEnd = current.length; + let newEnd = value.length; + while (oldEnd > from && newEnd > from && current[oldEnd - 1] === value[newEnd - 1]) { + oldEnd -= 1; + newEnd -= 1; + } valueRef.current = value; - view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); + view.dispatch({ changes: { from, to: oldEnd, insert: value.slice(from, newEnd) } }); }, [value]); useEffect(() => { diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx index 7c6866f08..a8d948762 100644 --- a/frontend/src/pages/inbounds/form/advanced-editors.tsx +++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx @@ -81,6 +81,47 @@ export function AdvancedSliceEditor({ ); } +function sameJsonValue(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((item, index) => sameJsonValue(item, right[index])) + ); + } + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; + const a = left as Record; + const b = right as Record; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => key in b && sameJsonValue(a[key], b[key])) + ); +} + +function updateTagOnly(raw: string, canonical: string): string | null { + let expected: Record; + try { + expected = JSON.parse(canonical) as Record; + if (typeof expected.tag !== 'string') return null; + } catch { + return null; + } + for (const match of raw.matchAll(/("tag"\s*:\s*)("(?:\\.|[^"\\])*")/g)) { + const start = match.index + match[1].length; + const candidate = + raw.slice(0, start) + JSON.stringify(expected.tag) + raw.slice(start + match[2].length); + try { + if (sameJsonValue(JSON.parse(candidate), expected)) return candidate; + } catch { + // Invalid intermediate buffers are replaced with the form's canonical JSON. + } + } + return null; +} + /* * The "All" editor shows the full inbound JSON in one editor: top-level * connection fields plus the three nested sub-objects (settings, @@ -96,7 +137,7 @@ export function AdvancedAllEditor({ streamEnabled: boolean; sniffingEnabled: boolean; }) { - const { control, setValue } = useFormContext(); + const { control, getValues, setValue } = useFormContext(); const wListen = useWatch({ control, name: 'listen' }); const wPort = useWatch({ control, name: 'port' }); const wProtocol = useWatch({ control, name: 'protocol' }); @@ -106,29 +147,30 @@ export function AdvancedAllEditor({ const wStream = useWatch({ control, name: 'streamSettings' }); const serialize = () => { + const current = getValues(); /* * Apply the same prune/normalize as the wire payload so the JSON * shown here is what the panel actually POSTs (no empty defaults, * disabled sniffing as { enabled: false }, finalmask dropped when * there are no masks). */ - const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; - if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { - settingsView.clients = normalizeClients(wProtocol, settingsView.clients); + const settingsView = (pruneEmpty(current.settings ?? {}) ?? {}) as Record; + if (typeof current.protocol === 'string' && Array.isArray(settingsView.clients)) { + settingsView.clients = normalizeClients(current.protocol, settingsView.clients); } const streamView = streamEnabled - ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) + ? ((pruneEmpty(current.streamSettings ?? {}) ?? {}) as Record) : undefined; dropLegacyOptionalEmpties(settingsView, streamView); const out: Record = { - listen: wListen ?? '', - port: wPort ?? 0, - protocol: wProtocol ?? '', - tag: wTag ?? '', + listen: current.listen ?? '', + port: current.port ?? 0, + protocol: current.protocol ?? '', + tag: current.tag ?? '', settings: settingsView, }; if (sniffingEnabled) { - out.sniffing = normalizeSniffing(wSniffing as Parameters[0]); + out.sniffing = normalizeSniffing(current.sniffing as Parameters[0]); } if (streamView) out.streamSettings = streamView; return JSON.stringify(out, null, 2); @@ -144,7 +186,7 @@ export function AdvancedAllEditor({ useEffect(() => { const formStr = serialize(); if (formStr === lastEmitRef.current) return; - setText(formStr); + setText((previous) => updateTagOnly(previous, formStr) ?? formStr); lastEmitRef.current = formStr; /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [ @@ -188,7 +230,9 @@ export function AdvancedAllEditor({ if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { setValue('streamSettings', parsed.streamSettings); } - lastEmitRef.current = next; + const canonical = serialize(); + lastEmitRef.current = canonical; + if (!sameJsonValue(parsed, JSON.parse(canonical))) setText(canonical); }} /> ); diff --git a/frontend/src/test/advanced-all-editor.test.tsx b/frontend/src/test/advanced-all-editor.test.tsx new file mode 100644 index 000000000..50c020f0a --- /dev/null +++ b/frontend/src/test/advanced-all-editor.test.tsx @@ -0,0 +1,126 @@ +import { useEffect } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { AdvancedAllEditor } from '@/pages/inbounds/form/advanced-editors'; + +vi.mock('@/components/form', () => ({ + JsonEditor: ({ value, onChange }: { value: string; onChange: (next: string) => void }) => ( +