From 7747c95afb7746df35f75c2e8c12175d30cf341f Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 3 Apr 2026 02:42:41 +0800 Subject: [PATCH] fix(config): make visual dirty tracking strictmode-safe --- src/hooks/useVisualConfig.ts | 515 ++++++++++++++++++++--------------- 1 file changed, 295 insertions(+), 220 deletions(-) diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index 1d9737c..10b97c1 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useReducer } from 'react'; import { isMap, parse as parseYaml, parseDocument } from 'yaml'; import type { PayloadFilterRule, @@ -189,7 +189,9 @@ export function getPayloadParamValidationError( } function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean { - return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param)))); + return rules.some((rule) => + rule.params.some((param) => Boolean(getPayloadParamValidationError(param))) + ); } function deepClone(value: T): T { @@ -197,7 +199,10 @@ function deepClone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } -function arePayloadModelEntriesEqual(left: PayloadRule['models'], right: PayloadRule['models']): boolean { +function arePayloadModelEntriesEqual( + left: PayloadRule['models'], + right: PayloadRule['models'] +): boolean { if (left === right) return true; if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { @@ -209,19 +214,17 @@ function arePayloadModelEntriesEqual(left: PayloadRule['models'], right: Payload return true; } -function arePayloadParamEntriesEqual(left: PayloadRule['params'], right: PayloadRule['params']): boolean { +function arePayloadParamEntriesEqual( + left: PayloadRule['params'], + right: PayloadRule['params'] +): boolean { if (left === right) return true; if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { const a = left[i]; const b = right[i]; if (!a || !b) return false; - if ( - a.id !== b.id || - a.path !== b.path || - a.valueType !== b.valueType || - a.value !== b.value - ) { + if (a.id !== b.id || a.path !== b.path || a.valueType !== b.valueType || a.value !== b.value) { return false; } } @@ -242,7 +245,10 @@ function arePayloadRulesEqual(left: PayloadRule[], right: PayloadRule[]): boolea return true; } -function arePayloadFilterRulesEqual(left: PayloadFilterRule[], right: PayloadFilterRule[]): boolean { +function arePayloadFilterRulesEqual( + left: PayloadFilterRule[], + right: PayloadFilterRule[] +): boolean { if (left === right) return true; if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { @@ -488,17 +494,266 @@ function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array rule.models.length > 0); } -export function useVisualConfig() { - const [visualValues, setVisualValuesState] = useState({ - ...DEFAULT_VISUAL_VALUES, - }); +type VisualConfigState = { + visualValues: VisualConfigValues; + baselineValues: VisualConfigValues; + dirtyFields: Set; + visualParseError: string | null; +}; - const [baselineValues, setBaselineValues] = useState({ - ...DEFAULT_VISUAL_VALUES, - }); - const [visualParseError, setVisualParseError] = useState(null); - const dirtyFieldsRef = useRef>(new Set()); - const [visualDirty, setVisualDirty] = useState(false); +type VisualConfigAction = + | { + type: 'load_success'; + values: VisualConfigValues; + } + | { + type: 'load_error'; + error: string; + } + | { + type: 'set_values'; + values: Partial; + }; + +function createInitialVisualConfigState(): VisualConfigState { + const initialValues = deepClone(DEFAULT_VISUAL_VALUES); + return { + visualValues: initialValues, + baselineValues: deepClone(initialValues), + dirtyFields: new Set(), + visualParseError: null, + }; +} + +function mergeVisualConfigValues( + currentValues: VisualConfigValues, + patch: Partial +): VisualConfigValues { + const nextValues: VisualConfigValues = { ...currentValues, ...patch } as VisualConfigValues; + if (patch.streaming) { + nextValues.streaming = { ...currentValues.streaming, ...patch.streaming }; + } + return nextValues; +} + +function getNextDirtyFields( + currentDirtyFields: Set, + patch: Partial, + nextValues: VisualConfigValues, + baselineValues: VisualConfigValues +): Set { + const nextDirtyFields = new Set(currentDirtyFields); + const updateDirty = (key: string, isEqual: boolean) => { + if (isEqual) { + nextDirtyFields.delete(key); + } else { + nextDirtyFields.add(key); + } + }; + + if (Object.prototype.hasOwnProperty.call(patch, 'host')) { + updateDirty('host', nextValues.host === baselineValues.host); + } + if (Object.prototype.hasOwnProperty.call(patch, 'port')) { + updateDirty('port', nextValues.port === baselineValues.port); + } + if (Object.prototype.hasOwnProperty.call(patch, 'tlsEnable')) { + updateDirty('tlsEnable', nextValues.tlsEnable === baselineValues.tlsEnable); + } + if (Object.prototype.hasOwnProperty.call(patch, 'tlsCert')) { + updateDirty('tlsCert', nextValues.tlsCert === baselineValues.tlsCert); + } + if (Object.prototype.hasOwnProperty.call(patch, 'tlsKey')) { + updateDirty('tlsKey', nextValues.tlsKey === baselineValues.tlsKey); + } + if (Object.prototype.hasOwnProperty.call(patch, 'rmAllowRemote')) { + updateDirty('rmAllowRemote', nextValues.rmAllowRemote === baselineValues.rmAllowRemote); + } + if (Object.prototype.hasOwnProperty.call(patch, 'rmSecretKey')) { + updateDirty('rmSecretKey', nextValues.rmSecretKey === baselineValues.rmSecretKey); + } + if (Object.prototype.hasOwnProperty.call(patch, 'rmDisableControlPanel')) { + updateDirty( + 'rmDisableControlPanel', + nextValues.rmDisableControlPanel === baselineValues.rmDisableControlPanel + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'rmPanelRepo')) { + updateDirty('rmPanelRepo', nextValues.rmPanelRepo === baselineValues.rmPanelRepo); + } + if (Object.prototype.hasOwnProperty.call(patch, 'authDir')) { + updateDirty('authDir', nextValues.authDir === baselineValues.authDir); + } + if (Object.prototype.hasOwnProperty.call(patch, 'apiKeysText')) { + updateDirty('apiKeysText', nextValues.apiKeysText === baselineValues.apiKeysText); + } + if (Object.prototype.hasOwnProperty.call(patch, 'debug')) { + updateDirty('debug', nextValues.debug === baselineValues.debug); + } + if (Object.prototype.hasOwnProperty.call(patch, 'commercialMode')) { + updateDirty('commercialMode', nextValues.commercialMode === baselineValues.commercialMode); + } + if (Object.prototype.hasOwnProperty.call(patch, 'loggingToFile')) { + updateDirty('loggingToFile', nextValues.loggingToFile === baselineValues.loggingToFile); + } + if (Object.prototype.hasOwnProperty.call(patch, 'logsMaxTotalSizeMb')) { + updateDirty( + 'logsMaxTotalSizeMb', + nextValues.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'usageStatisticsEnabled')) { + updateDirty( + 'usageStatisticsEnabled', + nextValues.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'proxyUrl')) { + updateDirty('proxyUrl', nextValues.proxyUrl === baselineValues.proxyUrl); + } + if (Object.prototype.hasOwnProperty.call(patch, 'forceModelPrefix')) { + updateDirty( + 'forceModelPrefix', + nextValues.forceModelPrefix === baselineValues.forceModelPrefix + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'requestRetry')) { + updateDirty('requestRetry', nextValues.requestRetry === baselineValues.requestRetry); + } + if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryCredentials')) { + updateDirty( + 'maxRetryCredentials', + nextValues.maxRetryCredentials === baselineValues.maxRetryCredentials + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryInterval')) { + updateDirty( + 'maxRetryInterval', + nextValues.maxRetryInterval === baselineValues.maxRetryInterval + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'wsAuth')) { + updateDirty('wsAuth', nextValues.wsAuth === baselineValues.wsAuth); + } + if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchProject')) { + updateDirty( + 'quotaSwitchProject', + nextValues.quotaSwitchProject === baselineValues.quotaSwitchProject + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchPreviewModel')) { + updateDirty( + 'quotaSwitchPreviewModel', + nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) { + updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy); + } + if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) { + updateDirty( + 'payloadDefaultRules', + arePayloadRulesEqual(nextValues.payloadDefaultRules, baselineValues.payloadDefaultRules) + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRawRules')) { + updateDirty( + 'payloadDefaultRawRules', + arePayloadRulesEqual(nextValues.payloadDefaultRawRules, baselineValues.payloadDefaultRawRules) + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRules')) { + updateDirty( + 'payloadOverrideRules', + arePayloadRulesEqual(nextValues.payloadOverrideRules, baselineValues.payloadOverrideRules) + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRawRules')) { + updateDirty( + 'payloadOverrideRawRules', + arePayloadRulesEqual( + nextValues.payloadOverrideRawRules, + baselineValues.payloadOverrideRawRules + ) + ); + } + if (Object.prototype.hasOwnProperty.call(patch, 'payloadFilterRules')) { + updateDirty( + 'payloadFilterRules', + arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules) + ); + } + if (patch.streaming) { + const streamingPatch = patch.streaming; + if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) { + updateDirty( + 'streaming.keepaliveSeconds', + nextValues.streaming.keepaliveSeconds === baselineValues.streaming.keepaliveSeconds + ); + } + if (Object.prototype.hasOwnProperty.call(streamingPatch, 'bootstrapRetries')) { + updateDirty( + 'streaming.bootstrapRetries', + nextValues.streaming.bootstrapRetries === baselineValues.streaming.bootstrapRetries + ); + } + if (Object.prototype.hasOwnProperty.call(streamingPatch, 'nonstreamKeepaliveInterval')) { + updateDirty( + 'streaming.nonstreamKeepaliveInterval', + nextValues.streaming.nonstreamKeepaliveInterval === + baselineValues.streaming.nonstreamKeepaliveInterval + ); + } + } + + return nextDirtyFields; +} + +function visualConfigReducer( + state: VisualConfigState, + action: VisualConfigAction +): VisualConfigState { + switch (action.type) { + case 'load_success': + return { + visualValues: action.values, + baselineValues: deepClone(action.values), + dirtyFields: new Set(), + visualParseError: null, + }; + case 'load_error': + return { + ...state, + visualParseError: action.error, + }; + case 'set_values': { + const nextValues = mergeVisualConfigValues(state.visualValues, action.values); + const nextDirtyFields = getNextDirtyFields( + state.dirtyFields, + action.values, + nextValues, + state.baselineValues + ); + + return { + ...state, + visualValues: nextValues, + dirtyFields: nextDirtyFields, + }; + } + default: + return state; + } +} + +export function useVisualConfig() { + const [state, dispatch] = useReducer( + visualConfigReducer, + undefined, + createInitialVisualConfigState + ); + const { visualValues, visualParseError } = state; + const visualDirty = state.dirtyFields.size > 0; const visualValidationErrors = useMemo( () => getVisualConfigValidationErrors(visualValues), [visualValues] @@ -543,14 +798,16 @@ export function useVisualConfig() { rmAllowRemote: Boolean(remoteManagement?.['allow-remote']), rmSecretKey: - typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '', + typeof remoteManagement?.['secret-key'] === 'string' + ? remoteManagement['secret-key'] + : '', rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']), rmPanelRepo: typeof remoteManagement?.['panel-github-repository'] === 'string' ? remoteManagement['panel-github-repository'] : typeof remoteManagement?.['panel-repo'] === 'string' ? remoteManagement['panel-repo'] - : '', + : '', authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '', apiKeysText: resolveApiKeysText(parsed), @@ -569,12 +826,9 @@ export function useVisualConfig() { wsAuth: Boolean(parsed['ws-auth']), quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true), - quotaSwitchPreviewModel: Boolean( - quotaExceeded?.['switch-preview-model'] ?? true - ), + quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true), - routingStrategy: - routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin', + routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin', payloadDefaultRules: parsePayloadRules(payload?.default), payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']), @@ -589,15 +843,11 @@ export function useVisualConfig() { }, }; - setVisualValuesState(newValues); - setBaselineValues(deepClone(newValues)); - setVisualParseError(null); - dirtyFieldsRef.current.clear(); - setVisualDirty(false); + dispatch({ type: 'load_success', values: newValues }); return { ok: true as const }; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Invalid YAML'; - setVisualParseError(message); + dispatch({ type: 'load_error', error: message }); return { ok: false as const, error: message }; } }, []); @@ -683,10 +933,7 @@ export function useVisualConfig() { ) { ensureMapInDoc(doc, ['quota-exceeded']); doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject); - doc.setIn( - ['quota-exceeded', 'switch-preview-model'], - values.quotaSwitchPreviewModel - ); + doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel); deleteIfMapEmpty(doc, ['quota-exceeded']); } @@ -697,9 +944,13 @@ export function useVisualConfig() { } const keepaliveSeconds = - typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : ''; + typeof values.streaming?.keepaliveSeconds === 'string' + ? values.streaming.keepaliveSeconds + : ''; const bootstrapRetries = - typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : ''; + typeof values.streaming?.bootstrapRetries === 'string' + ? values.streaming.bootstrapRetries + : ''; const nonstreamKeepaliveInterval = typeof values.streaming?.nonstreamKeepaliveInterval === 'string' ? values.streaming.nonstreamKeepaliveInterval @@ -714,11 +965,7 @@ export function useVisualConfig() { deleteIfMapEmpty(doc, ['streaming']); } - setIntFromStringInDoc( - doc, - ['nonstream-keepalive-interval'], - nonstreamKeepaliveInterval - ); + setIntFromStringInDoc(doc, ['nonstream-keepalive-interval'], nonstreamKeepaliveInterval); if ( docHas(doc, ['payload']) || @@ -780,181 +1027,9 @@ export function useVisualConfig() { [visualValues] ); - const setVisualValues = useCallback( - (newValues: Partial) => { - setVisualValuesState((prev) => { - const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues; - if (newValues.streaming) { - next.streaming = { ...prev.streaming, ...newValues.streaming }; - } - - const dirtyFields = dirtyFieldsRef.current; - const updateDirty = (key: string, isEqual: boolean) => { - if (isEqual) { - dirtyFields.delete(key); - } else { - dirtyFields.add(key); - } - }; - - if (Object.prototype.hasOwnProperty.call(newValues, 'host')) { - updateDirty('host', next.host === baselineValues.host); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'port')) { - updateDirty('port', next.port === baselineValues.port); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'tlsEnable')) { - updateDirty('tlsEnable', next.tlsEnable === baselineValues.tlsEnable); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'tlsCert')) { - updateDirty('tlsCert', next.tlsCert === baselineValues.tlsCert); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'tlsKey')) { - updateDirty('tlsKey', next.tlsKey === baselineValues.tlsKey); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'rmAllowRemote')) { - updateDirty('rmAllowRemote', next.rmAllowRemote === baselineValues.rmAllowRemote); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'rmSecretKey')) { - updateDirty('rmSecretKey', next.rmSecretKey === baselineValues.rmSecretKey); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'rmDisableControlPanel')) { - updateDirty( - 'rmDisableControlPanel', - next.rmDisableControlPanel === baselineValues.rmDisableControlPanel - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'rmPanelRepo')) { - updateDirty('rmPanelRepo', next.rmPanelRepo === baselineValues.rmPanelRepo); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'authDir')) { - updateDirty('authDir', next.authDir === baselineValues.authDir); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'apiKeysText')) { - updateDirty('apiKeysText', next.apiKeysText === baselineValues.apiKeysText); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'debug')) { - updateDirty('debug', next.debug === baselineValues.debug); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'commercialMode')) { - updateDirty('commercialMode', next.commercialMode === baselineValues.commercialMode); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'loggingToFile')) { - updateDirty('loggingToFile', next.loggingToFile === baselineValues.loggingToFile); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'logsMaxTotalSizeMb')) { - updateDirty( - 'logsMaxTotalSizeMb', - next.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'usageStatisticsEnabled')) { - updateDirty( - 'usageStatisticsEnabled', - next.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'proxyUrl')) { - updateDirty('proxyUrl', next.proxyUrl === baselineValues.proxyUrl); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'forceModelPrefix')) { - updateDirty( - 'forceModelPrefix', - next.forceModelPrefix === baselineValues.forceModelPrefix - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'requestRetry')) { - updateDirty('requestRetry', next.requestRetry === baselineValues.requestRetry); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'maxRetryCredentials')) { - updateDirty( - 'maxRetryCredentials', - next.maxRetryCredentials === baselineValues.maxRetryCredentials - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'maxRetryInterval')) { - updateDirty( - 'maxRetryInterval', - next.maxRetryInterval === baselineValues.maxRetryInterval - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'wsAuth')) { - updateDirty('wsAuth', next.wsAuth === baselineValues.wsAuth); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'quotaSwitchProject')) { - updateDirty( - 'quotaSwitchProject', - next.quotaSwitchProject === baselineValues.quotaSwitchProject - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'quotaSwitchPreviewModel')) { - updateDirty( - 'quotaSwitchPreviewModel', - next.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'routingStrategy')) { - updateDirty('routingStrategy', next.routingStrategy === baselineValues.routingStrategy); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'payloadDefaultRules')) { - updateDirty( - 'payloadDefaultRules', - arePayloadRulesEqual(next.payloadDefaultRules, baselineValues.payloadDefaultRules) - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'payloadDefaultRawRules')) { - updateDirty( - 'payloadDefaultRawRules', - arePayloadRulesEqual(next.payloadDefaultRawRules, baselineValues.payloadDefaultRawRules) - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'payloadOverrideRules')) { - updateDirty( - 'payloadOverrideRules', - arePayloadRulesEqual(next.payloadOverrideRules, baselineValues.payloadOverrideRules) - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'payloadOverrideRawRules')) { - updateDirty( - 'payloadOverrideRawRules', - arePayloadRulesEqual(next.payloadOverrideRawRules, baselineValues.payloadOverrideRawRules) - ); - } - if (Object.prototype.hasOwnProperty.call(newValues, 'payloadFilterRules')) { - updateDirty( - 'payloadFilterRules', - arePayloadFilterRulesEqual(next.payloadFilterRules, baselineValues.payloadFilterRules) - ); - } - if (newValues.streaming) { - const streaming = newValues.streaming; - if (Object.prototype.hasOwnProperty.call(streaming, 'keepaliveSeconds')) { - updateDirty( - 'streaming.keepaliveSeconds', - next.streaming.keepaliveSeconds === baselineValues.streaming.keepaliveSeconds - ); - } - if (Object.prototype.hasOwnProperty.call(streaming, 'bootstrapRetries')) { - updateDirty( - 'streaming.bootstrapRetries', - next.streaming.bootstrapRetries === baselineValues.streaming.bootstrapRetries - ); - } - if (Object.prototype.hasOwnProperty.call(streaming, 'nonstreamKeepaliveInterval')) { - updateDirty( - 'streaming.nonstreamKeepaliveInterval', - next.streaming.nonstreamKeepaliveInterval === - baselineValues.streaming.nonstreamKeepaliveInterval - ); - } - } - - setVisualDirty(dirtyFields.size > 0); - return next; - }); - }, - [baselineValues] - ); + const setVisualValues = useCallback((newValues: Partial) => { + dispatch({ type: 'set_values', values: newValues }); + }, []); return { visualValues,