mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 14:55:02 +03:00
fix(inbounds): preserve formatting in the advanced JSON editor (#6857)
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.
This commit is contained in:
@@ -157,8 +157,18 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(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(() => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const b = right as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
try {
|
||||
expected = JSON.parse(canonical) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
|
||||
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
|
||||
const settingsView = (pruneEmpty(current.settings ?? {}) ?? {}) as Record<string, unknown>;
|
||||
if (typeof current.protocol === 'string' && Array.isArray(settingsView.clients)) {
|
||||
settingsView.clients = normalizeClients(current.protocol, settingsView.clients);
|
||||
}
|
||||
const streamView = streamEnabled
|
||||
? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
|
||||
? ((pruneEmpty(current.streamSettings ?? {}) ?? {}) as Record<string, unknown>)
|
||||
: undefined;
|
||||
dropLegacyOptionalEmpties(settingsView, streamView);
|
||||
const out: Record<string, unknown> = {
|
||||
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<typeof normalizeSniffing>[0]);
|
||||
out.sniffing = normalizeSniffing(current.sniffing as Parameters<typeof normalizeSniffing>[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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 }) => (
|
||||
<textarea
|
||||
aria-label="Advanced inbound JSON"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
function Harness({ autoTag = false }: { autoTag?: boolean }) {
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
listen: '',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
tag: autoTag ? 'auto-443' : 'initial',
|
||||
settings: { clients: [] },
|
||||
sniffing: { enabled: false },
|
||||
streamSettings: { network: 'tcp' },
|
||||
},
|
||||
});
|
||||
const port = useWatch({ control: methods.control, name: 'port' });
|
||||
|
||||
useEffect(() => {
|
||||
if (autoTag) methods.setValue('tag', `auto-${port}`);
|
||||
}, [autoTag, methods, port]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<AdvancedAllEditor streamEnabled sniffingEnabled />
|
||||
<button type="button" onClick={() => methods.setValue('tag', 'external')}>
|
||||
Update form externally
|
||||
</button>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function flushFormEffects() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function editor(): HTMLTextAreaElement {
|
||||
return screen.getByRole('textbox', { name: 'Advanced inbound JSON' }) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('AdvancedAllEditor JSON buffer (#6854)', () => {
|
||||
it('preserves a valid formatting-only edit instead of removing an entered newline', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const next = input.value.replace('"port": 443,', '"port": 443,\n');
|
||||
|
||||
fireEvent.change(input, { target: { value: next } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(input.value).toBe(next);
|
||||
expect(input.value).toContain('"port": 443,\n\n');
|
||||
});
|
||||
|
||||
it('preserves valid edits and reflects external form updates', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const next = input.value.replace('"tag": "initial"', '"tag": "edited"');
|
||||
|
||||
fireEvent.change(input, { target: { value: next } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(input.value).toBe(next);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update form externally' }));
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).tag).toBe('external');
|
||||
expect(input.value).toContain('"tag": "external"');
|
||||
});
|
||||
|
||||
it('does not show a rejected port string when the form still holds the numeric port', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: input.value.replace('"port": 443', '"port": "8443"') },
|
||||
});
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).port).toBe(443);
|
||||
expect(input.value).not.toContain('"port": "8443"');
|
||||
});
|
||||
|
||||
it('does not silently accept removing a still-enabled form slice', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const removed = JSON.parse(input.value) as Record<string, unknown>;
|
||||
delete removed.sniffing;
|
||||
|
||||
fireEvent.change(input, { target: { value: JSON.stringify(removed, null, 2) } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).sniffing).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('keeps whitespace when an auto-tag reacts to a port edit', async () => {
|
||||
render(<Harness autoTag />);
|
||||
const input = editor();
|
||||
const formatted = input.value.replace('"port": 443,', '"port": 443,\n');
|
||||
|
||||
fireEvent.change(input, { target: { value: formatted } });
|
||||
await flushFormEffects();
|
||||
fireEvent.change(input, {
|
||||
target: { value: input.value.replace('"port": 443,', '"port": 8443,') },
|
||||
});
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value)).toMatchObject({ port: 8443, tag: 'auto-8443' });
|
||||
expect(input.value).toContain('"port": 8443,\n\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import JsonEditor from '@/components/form/JsonEditor';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
describe('JsonEditor external synchronization (#6854)', () => {
|
||||
it('preserves the cursor after an external edit to an earlier JSON field', async () => {
|
||||
const initial = '{ "tag": "old", "port": 443 }';
|
||||
const updated = '{ "tag": "renamed", "port": 443 }';
|
||||
const { container, rerender } = render(
|
||||
<ThemeProvider>
|
||||
<JsonEditor value={initial} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const root = container.querySelector<HTMLElement>('.cm-editor');
|
||||
if (!root) throw new Error('CodeMirror editor was not mounted');
|
||||
const view = EditorView.findFromDOM(root);
|
||||
if (!view) throw new Error('CodeMirror view is missing');
|
||||
const offsetInPort = 3;
|
||||
|
||||
await act(async () => {
|
||||
view.dispatch({ selection: { anchor: initial.indexOf('"port"') + offsetInPort } });
|
||||
});
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<JsonEditor value={updated} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(view.state.doc.toString()).toBe(updated);
|
||||
expect(view.state.selection.main.head).toBe(updated.indexOf('"port"') + offsetInPort);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user