Compare commits

...

4 Commits

Author SHA1 Message Date
Sanaei 8a8da88548 fix(frontend): isolate swagger deps from main vendor chunk
Keep swagger-ui-react and its transitive dependencies in the lazy swagger chunk so the initial panel bundle stays smaller. This avoids eager loading the OpenAPI UI on first paint while keeping the API docs route unchanged.
2026-08-12 16:11:47 +02:00
Sanaei 1f846c3cb2 fix(frontend): clean test validation output 2026-08-12 15:35:20 +02:00
Sanaei 1c255fc00c chore(frontend): bump npm dependencies
Refresh frontend package versions and regenerate the lockfile. This updates core UI and tooling packages including Ant Design, React Hook Form, Storybook, Vite, eslint/typescript-eslint, @noble/hashes, persian-calendar-suite, and swagger-ui-react to pick up the latest fixes and minor improvements.
2026-08-12 14:06:18 +02:00
dependabot[bot] 75032fd498 chore(deps): bump dompurify (#6193)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [dompurify](https://github.com/cure53/DOMPurify).


Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 13:40:13 +02:00
12 changed files with 666 additions and 609 deletions
+536 -582
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -31,30 +31,30 @@
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.7.1",
"@noble/hashes": "^2.2.0",
"@noble/hashes": "^2.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.3",
"antd": "^6.6.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"persian-calendar-suite": "^1.5.6",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.84.0",
"react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11",
"react-router": "^8.3.0",
"swagger-ui-react": "^5.32.12",
"swagger-ui-react": "^5.32.13",
"uplot": "^1.6.32",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.6",
"@storybook/addon-docs": "^10.5.6",
"@storybook/addon-vitest": "^10.5.6",
"@storybook/react-vite": "^10.5.6",
"@storybook/addon-a11y": "^10.5.7",
"@storybook/addon-docs": "^10.5.7",
"@storybook/addon-vitest": "^10.5.7",
"@storybook/react-vite": "^10.5.7",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
@@ -63,19 +63,19 @@
"@vitejs/plugin-react": "^6.0.5",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.9.0",
"globals": "^17.11.0",
"husky": "^9.1.7",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"msw": "^2.15.0",
"playwright": "^1.62.1",
"storybook": "^10.5.6",
"storybook": "^10.5.7",
"typescript": "6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "8.2.0",
"typescript-eslint": "^8.67.0",
"vite": "8.2.1",
"vitest": "^4.1.10"
},
"overrides": {
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
@@ -17,7 +17,7 @@ type SettingSaveResult = {
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
const validated = parseMsg(msg, AllSettingResponseSchema, 'setting/all');
return validated.obj;
}
+9
View File
@@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
// Existing installations can contain regex values saved before the backend
// enforced its 2,048-character limit. Accept those values when reading so the
// settings page can display and let users correct them, while keeping the
// stricter schema above for outgoing updates.
export const AllSettingResponseSchema = AllSettingSchema.extend({
subJsonUserAgentRegex: z.string().optional(),
subClashUserAgentRegex: z.string().optional(),
});
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
@@ -99,7 +99,11 @@ describe('useClients query gating', () => {
});
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/inbounds/options') ? [] : emptyPage,
));
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+3
View File
@@ -35,6 +35,7 @@ const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, stat
describe('HttpUtil', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
it('unwraps a success envelope and shows a success toast', async () => {
@@ -80,6 +81,7 @@ describe('HttpUtil', () => {
expect(msg.success).toBe(false);
expect(msg.msg).toBe('bad input');
expect(console.error).not.toHaveBeenCalled();
});
it('maps a thrown native error to a failure Msg via its message', async () => {
@@ -88,6 +90,7 @@ describe('HttpUtil', () => {
const msg = await HttpUtil.get('/x', undefined, { silent: true });
expect(msg.msg).toBe('Network down');
expect(console.error).not.toHaveBeenCalled();
});
it('returns "No response data" for an empty body', async () => {
+16 -3
View File
@@ -48,6 +48,16 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// jsdom does not implement pseudo-element styles or Range geometry. Ant
// Design and CodeMirror use these APIs for layout, so supply harmless test
// fallbacks instead of emitting noisy "Not implemented" errors.
const nativeGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
if (!Range.prototype.getClientRects) {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
}
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: 'en-US',
@@ -75,9 +85,12 @@ afterEach(async () => {
}
});
import { HttpUtil } from '@/utils';
import { HttpUtil, Msg } from '@/utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
true,
'',
url.includes('/panel/api/inbounds/options') ? [] : {},
));
+3 -1
View File
@@ -13,9 +13,10 @@ afterEach(() => {
});
describe('useAllSettings', () => {
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
const subJsonUserAgentRegex = 'x'.repeat(2_049);
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
@@ -25,6 +26,7 @@ describe('useAllSettings', () => {
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
expect(warning).not.toHaveBeenCalled();
});
it('keeps an edited setting when a refetch returns older server data', async () => {
+15
View File
@@ -13,10 +13,15 @@ afterEach(() => {
describe('parseMsg', () => {
it('rejects a successful response whose payload violates its schema', () => {
const msg = new Msg(true, '', { id: 'not-a-number' });
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
'test/value response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] test/value response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['id'] })]),
);
});
it('preserves a missing successful payload for callers that handle empty values', () => {
@@ -25,17 +30,27 @@ describe('parseMsg', () => {
it('rejects malformed paged-client payloads', () => {
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
'clients/list/paged response failed validation',
);
expect(warning).toHaveBeenCalledWith(
'[zod] clients/list/paged response failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['total'] })]),
);
});
});
describe('fetchXrayConfig', () => {
it('keeps a malformed xray payload available for repair', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
expect(warning).toHaveBeenCalledWith(
'[zod] xray/ config payload failed validation',
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] })]),
);
});
});
+8 -4
View File
@@ -75,11 +75,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('GET request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('GET request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
@@ -92,11 +94,13 @@ export class HttpUtil {
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('POST request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
if (!silent) {
console.error('POST request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
+52
View File
@@ -151,6 +151,57 @@ function makeBackendProxy(target) {
};
}
// Deps only reachable through swagger-ui-react (verified via `npm ls`). The
// catch-all `vendor` chunk would otherwise eager-load them on first paint,
// although the api-docs page is the only lazy route importing them.
const SWAGGER_ONLY_DEPS = [
'@babel/runtime-corejs3',
'@scarf/scarf',
'@swagger-api/apidom-',
'@swaggerexpert/',
'base64-js',
'buffer',
'classnames',
'css.escape',
'deep-extend',
'dompurify',
'fast-json-patch',
'highlight.js',
'highlightjs-vue',
'ieee754',
'immutable',
'js-file-download',
'js-yaml',
'lodash',
'lowlight',
'neotraverse',
'node-abort-controller',
'openapi-path-templating',
'openapi-server-url-templating',
'prismjs',
'prop-types',
'ramda',
'ramda-adjunct',
'randexp',
'react-copy-to-clipboard',
'react-debounce-input',
'react-immutable-proptypes',
'react-immutable-pure-component',
'react-inspector',
'react-redux',
'react-syntax-highlighter',
'redux',
'redux-immutable',
'remarkable',
'reselect',
'serialize-error',
'sha.js',
'url-parse',
'xml',
'xml-but-prettier',
'zenscroll',
];
export default defineConfig({
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
resolve: {
@@ -222,6 +273,7 @@ export default defineConfig({
id.includes('/node_modules/swagger-ui-react/')
|| id.includes('/node_modules/swagger-ui/')
|| id.includes('/node_modules/swagger-client/')
|| SWAGGER_ONLY_DEPS.some((dep) => id.includes(`/node_modules/${dep}/`))
) return 'vendor-swagger';
if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
if (id.includes('dayjs')) return 'vendor-dayjs';
+3 -2
View File
@@ -1,12 +1,11 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const dirname = import.meta.dirname;
export default defineConfig({
plugins: [react()],
@@ -17,6 +16,8 @@ export default defineConfig({
},
test: {
globals: false,
// Keep jsdom-heavy form tests within the memory budget of local and CI runners.
maxWorkers: 2,
projects: [
{
extends: true,