mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
perf(ui): drop zod from the Control UI and lazy-load json5 with the config surfaces (#110623)
* perf(ui): drop zod and lazy-load json5 out of Control UI startup zod's only UI consumer was the custom-theme shape layer, whose deep CSS validators already re-check every token; replace the two shallow schemas with a plain record reader and delete the zod jitless CSP shim + test. Startup keeps one schema library total (typebox, protocol-owned, already lazy via the approval page). json5 now loads through a lazy runtime boundary: strict JSON.parse is the fast path, the parser warms with the config editor and whenever a config snapshot or raw draft actually needs JSON5 (comments, trailing commas). The redaction sanitize path parses the authoritative raw once at snapshot ingestion (async-safe) and submits against the carried parsed fact, so secret-placeholder handling never races the lazy parser. Startup JS: 321.8 -> 295.8 KiB gzip, 13 -> 12 requests; budgets ratchet to 310 KiB / 18 requests. * fix(ui): gate config submits on pending JSON5 original parse A JSON5 config racing the first parser load could reach the sanitize step with a null parsed original and pass redaction placeholders through. setConfigRawOriginal now parses synchronously whenever the parser is warm and tracks a pending promise otherwise; submit, auto-save, and teardown flush paths defer to that promise (teardown keeps its synchronous prefix when no parse is pending). * fix(ui): keep teardown config flush synchronous and JSON5 diff cache non-sticky Teardown flush must dispatch before unload destroys the context; the gateway's restore-or-reject sentinel contract backs the rare unsanitized window. Raw-diff parse failures no longer cache while the lazy JSON5 parser is still loading, so a transient cold-parser miss retries on the next render instead of pinning an empty diff. * fix(ui): harden lazy JSON5 boundary against double-submit and failed loads Claim the config busy flag before awaiting a pending JSON5 original parse so a second click cannot slip past the busy state (autosave overlap is already serialized by the in-flight registry and drain discipline). A rejected json5 chunk import now resets the loader for retry, the per-state pending promise is never-rejecting and self-clearing, and fire-and-forget warms swallow rejections. * docs(ui): note autosave entry serialization at the JSON5 parse await * fix(ui): fill raw pending-changes diff once the lazy JSON5 parser lands First diff open could race the parser chunk and render an empty list with nothing scheduling a retry; renderConfig now re-renders when the warm completes, and the browser test warms the parser in setup to assert the steady state the view module guarantees in prod.
This commit is contained in:
committed by
GitHub
parent
684ae080d4
commit
f98bcb7fa7
@@ -10,9 +10,9 @@ const KIB = 1024;
|
||||
// Small, explicit headroom over the optimized baseline. Budget changes should
|
||||
// accompany an intentional loading or chunking decision.
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 20,
|
||||
startupJsRequests: 18,
|
||||
startupCssRequests: 1,
|
||||
startupJsGzipBytes: 340 * KIB,
|
||||
startupJsGzipBytes: 310 * KIB,
|
||||
startupCssGzipBytes: 42 * KIB,
|
||||
largestJsGzipBytes: 215 * KIB,
|
||||
largestCssGzipBytes: 42 * KIB,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Control UI tests cover Zod initialization under the Gateway's strict CSP.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("custom theme Zod initialization", () => {
|
||||
it("enables jitless mode before object schemas probe dynamic code", async () => {
|
||||
vi.resetModules();
|
||||
const { z } = await import("zod");
|
||||
const config = z.config();
|
||||
const previousJitless = config.jitless;
|
||||
delete config.jitless;
|
||||
|
||||
const NativeFunction = globalThis.Function;
|
||||
let evalProbeAttempted = false;
|
||||
const FunctionProxy = new Proxy(NativeFunction, {
|
||||
apply(target, thisArg, args) {
|
||||
evalProbeAttempted ||= args.length === 1 && args[0] === "";
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
},
|
||||
construct(target, args, newTarget) {
|
||||
evalProbeAttempted ||= args.length === 1 && args[0] === "";
|
||||
return Reflect.construct(target, args, newTarget);
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("Function", FunctionProxy);
|
||||
|
||||
try {
|
||||
await import("./custom-theme.ts");
|
||||
|
||||
expect(z.config().jitless).toBe(true);
|
||||
expect(evalProbeAttempted).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
if (previousJitless === undefined) {
|
||||
delete config.jitless;
|
||||
} else {
|
||||
config.jitless = previousJitless;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
+39
-81
@@ -1,12 +1,7 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
// Control UI module implements custom theme behavior.
|
||||
import { z } from "zod";
|
||||
import { normalizeOptionalString } from "../lib/string-coerce.ts";
|
||||
|
||||
// The Control UI CSP forbids dynamic code generation. Zod snapshots this flag
|
||||
// when z.object() is constructed, before its eval-backed fast path can probe.
|
||||
z.config({ jitless: true });
|
||||
|
||||
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
|
||||
const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme";
|
||||
@@ -93,29 +88,6 @@ const MODE_TOKEN_ORDER = [
|
||||
type ModeTokenName = (typeof MODE_TOKEN_ORDER)[number];
|
||||
type ThemeTokenMap = Record<ModeTokenName, string>;
|
||||
|
||||
const REQUIRED_TWEAKCN_MODE_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
type RequiredTweakcnModeVar = (typeof REQUIRED_TWEAKCN_MODE_VARS)[number];
|
||||
|
||||
export type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
@@ -125,40 +97,15 @@ export type ImportedCustomTheme = {
|
||||
dark: ThemeTokenMap;
|
||||
};
|
||||
|
||||
const cssTokenSchema = z.string().max(MAX_CSS_TOKEN_LENGTH);
|
||||
|
||||
function createStringShape<const T extends readonly string[]>(keys: T) {
|
||||
return Object.fromEntries(keys.map((key) => [key, cssTokenSchema])) as Record<
|
||||
T[number],
|
||||
typeof cssTokenSchema
|
||||
>;
|
||||
// Shape checks are intentionally shallow: normalizeStoredTokenMap and
|
||||
// resolveModeVar re-validate every token (presence, length, safe CSS) and
|
||||
// throw on anything off, so no schema library is needed at this boundary.
|
||||
function readThemeRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
const tweakcnThemeSchema = z.object({
|
||||
name: z.string().max(80).optional(),
|
||||
cssVars: z.object({
|
||||
theme: z
|
||||
.object({
|
||||
"font-sans": cssTokenSchema.optional(),
|
||||
"font-mono": cssTokenSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
light: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
dark: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
}),
|
||||
});
|
||||
|
||||
const importedCustomThemeSchema = z.object({
|
||||
sourceUrl: z.string(),
|
||||
themeId: z.string(),
|
||||
label: z.string(),
|
||||
importedAt: z.string(),
|
||||
light: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
dark: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
});
|
||||
|
||||
type TweakcnThemePayload = z.infer<typeof tweakcnThemeSchema>;
|
||||
|
||||
type TweakcnThemeResolution = {
|
||||
sourceUrl: string;
|
||||
fetchUrl: string;
|
||||
@@ -305,7 +252,7 @@ function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap {
|
||||
return Object.fromEntries(entries) as ThemeTokenMap;
|
||||
}
|
||||
|
||||
function normalizeStoredTokenMap(value: Record<string, string> | undefined): ThemeTokenMap | null {
|
||||
function normalizeStoredTokenMap(value: Record<string, unknown> | undefined): ThemeTokenMap | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
@@ -321,8 +268,8 @@ function normalizeStoredTokenMap(value: Record<string, string> | undefined): The
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, string | undefined>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
@@ -344,8 +291,8 @@ function resolveModeVar(
|
||||
|
||||
function normalizeModeTokenMap(
|
||||
mode: "light" | "dark",
|
||||
theme: Record<RequiredTweakcnModeVar, string>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | undefined,
|
||||
): ThemeTokenMap {
|
||||
const isLight = mode === "light";
|
||||
const contrastTarget = isLight ? "black" : "white";
|
||||
@@ -461,22 +408,31 @@ function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution {
|
||||
}
|
||||
|
||||
export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme | null {
|
||||
const parsed = importedCustomThemeSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
const record = readThemeRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const { sourceUrl, themeId, label, importedAt } = record;
|
||||
if (
|
||||
typeof sourceUrl !== "string" ||
|
||||
typeof themeId !== "string" ||
|
||||
typeof label !== "string" ||
|
||||
typeof importedAt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
requireThemeId(parsed.data.themeId);
|
||||
const light = normalizeStoredTokenMap(parsed.data.light);
|
||||
const dark = normalizeStoredTokenMap(parsed.data.dark);
|
||||
requireThemeId(themeId);
|
||||
const light = normalizeStoredTokenMap(readThemeRecord(record.light) ?? undefined);
|
||||
const dark = normalizeStoredTokenMap(readThemeRecord(record.dark) ?? undefined);
|
||||
if (!light || !dark) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceUrl: parsed.data.sourceUrl,
|
||||
themeId: parsed.data.themeId,
|
||||
label: describeThemeLabel(parsed.data.label),
|
||||
importedAt: parsed.data.importedAt,
|
||||
sourceUrl,
|
||||
themeId,
|
||||
label: describeThemeLabel(label),
|
||||
importedAt,
|
||||
light,
|
||||
dark,
|
||||
};
|
||||
@@ -489,19 +445,21 @@ function normalizeImportedCustomTheme(
|
||||
payload: unknown,
|
||||
resolution: Pick<TweakcnThemeResolution, "sourceUrl" | "themeId">,
|
||||
): ImportedCustomTheme {
|
||||
const parsed = tweakcnThemeSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
const record = readThemeRecord(payload);
|
||||
const cssVars = readThemeRecord(record?.cssVars);
|
||||
const light = readThemeRecord(cssVars?.light);
|
||||
const dark = readThemeRecord(cssVars?.dark);
|
||||
const shared = cssVars?.theme === undefined ? undefined : readThemeRecord(cssVars.theme);
|
||||
if (!record || !cssVars || !light || !dark || shared === null) {
|
||||
throw new Error("tweakcn returned an invalid theme payload.");
|
||||
}
|
||||
const data: TweakcnThemePayload = parsed.data;
|
||||
const shared = data.cssVars.theme;
|
||||
return {
|
||||
sourceUrl: resolution.sourceUrl,
|
||||
themeId: resolution.themeId,
|
||||
label: describeThemeLabel(data.name),
|
||||
label: describeThemeLabel(normalizeOptionalString(record.name)),
|
||||
importedAt: new Date().toISOString(),
|
||||
light: normalizeModeTokenMap("light", data.cssVars.light, shared),
|
||||
dark: normalizeModeTokenMap("dark", data.cssVars.dark, shared),
|
||||
light: normalizeModeTokenMap("light", light, shared),
|
||||
dark: normalizeModeTokenMap("dark", dark, shared),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,7 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
};
|
||||
|
||||
expect(
|
||||
sanitizeRedactedFormForSubmit(
|
||||
form,
|
||||
originalForm,
|
||||
'{\n gateway: {\n mode: "remote"\n }\n}\n',
|
||||
),
|
||||
sanitizeRedactedFormForSubmit(form, originalForm, { gateway: { mode: "remote" } }),
|
||||
).toEqual({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
@@ -113,11 +109,9 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
const originalForm = cloneConfigObject(form);
|
||||
|
||||
expect(
|
||||
sanitizeRedactedFormForSubmit(
|
||||
form,
|
||||
originalForm,
|
||||
'{\n gateway: {\n mode: "remote",\n remote: {\n token: "__OPENCLAW_REDACTED__"\n }\n }\n}\n',
|
||||
),
|
||||
sanitizeRedactedFormForSubmit(form, originalForm, {
|
||||
gateway: { mode: "remote", remote: { token: "__OPENCLAW_REDACTED__" } },
|
||||
}),
|
||||
).toEqual(form);
|
||||
});
|
||||
|
||||
@@ -135,13 +129,9 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
sanitizeRedactedFormForSubmit(
|
||||
form,
|
||||
originalForm,
|
||||
"{\n gateway: {\n remote: {}\n }\n}\n",
|
||||
),
|
||||
).toEqual(form);
|
||||
expect(sanitizeRedactedFormForSubmit(form, originalForm, { gateway: { remote: {} } })).toEqual(
|
||||
form,
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes empty object parents when they are absent from original raw config", () => {
|
||||
@@ -157,9 +147,7 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
};
|
||||
const originalForm = cloneConfigObject(form);
|
||||
|
||||
expect(
|
||||
sanitizeRedactedFormForSubmit(form, originalForm, '{\n ui: { theme: "dark" }\n}\n'),
|
||||
).toEqual({
|
||||
expect(sanitizeRedactedFormForSubmit(form, originalForm, { ui: { theme: "dark" } })).toEqual({
|
||||
ui: { theme: "dark" },
|
||||
});
|
||||
});
|
||||
@@ -175,15 +163,13 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
const originalForm = cloneConfigObject(form);
|
||||
|
||||
expect(
|
||||
sanitizeRedactedFormForSubmit(
|
||||
form,
|
||||
originalForm,
|
||||
'{\n channels: { slack: { tokens: ["second-token"] } }\n}\n',
|
||||
),
|
||||
sanitizeRedactedFormForSubmit(form, originalForm, {
|
||||
channels: { slack: { tokens: ["second-token"] } },
|
||||
}),
|
||||
).toEqual(form);
|
||||
});
|
||||
|
||||
it("leaves the form unchanged when original raw config cannot be parsed", () => {
|
||||
it("leaves the form unchanged when the original raw config has no parsed snapshot", () => {
|
||||
const form = {
|
||||
gateway: {
|
||||
remote: {
|
||||
@@ -193,7 +179,7 @@ describe("sanitizeRedactedFormForSubmit", () => {
|
||||
};
|
||||
const originalForm = cloneConfigObject(form);
|
||||
|
||||
expect(sanitizeRedactedFormForSubmit(form, originalForm, "{")).toEqual(form);
|
||||
expect(sanitizeRedactedFormForSubmit(form, originalForm, null)).toEqual(form);
|
||||
});
|
||||
});
|
||||
describe("prototype pollution prevention", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Control UI controller manages form utils gateway state.
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import JSON5 from "json5";
|
||||
|
||||
export function cloneConfigObject<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
@@ -93,19 +92,11 @@ function sanitizeRedactedValue(params: {
|
||||
export function sanitizeRedactedFormForSubmit(
|
||||
form: Record<string, unknown>,
|
||||
originalForm: Record<string, unknown> | null | undefined,
|
||||
originalRaw: string,
|
||||
parsedOriginalRaw: Record<string, unknown> | null,
|
||||
): Record<string, unknown> {
|
||||
if (!originalForm || !originalRaw) {
|
||||
return form;
|
||||
}
|
||||
|
||||
let parsedOriginalRaw: unknown;
|
||||
try {
|
||||
parsedOriginalRaw = JSON5.parse(originalRaw);
|
||||
} catch {
|
||||
return form;
|
||||
}
|
||||
if (!isRecord(parsedOriginalRaw)) {
|
||||
// Callers parse the original raw once at snapshot ingestion so this submit
|
||||
// path stays synchronous and never races the lazy JSON5 parser.
|
||||
if (!originalForm || !parsedOriginalRaw) {
|
||||
return form;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Control UI runtime config capability and shared config-domain mutations.
|
||||
import JSON5 from "json5";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts";
|
||||
import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts";
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
serializeConfigForm,
|
||||
setPathValue,
|
||||
} from "../config-form-utils.ts";
|
||||
import { parseJson5Text, warmJson5 } from "../json5-runtime.ts";
|
||||
import { createAppliedConfigRefreshController } from "./applied-refresh.ts";
|
||||
|
||||
export type ConfigAutoSaveStatus = "idle" | "saving" | "saved" | "error" | "conflict";
|
||||
@@ -44,6 +44,8 @@ type ConfigState = {
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configRawOriginal: string;
|
||||
configRawOriginalParsed: Record<string, unknown> | null;
|
||||
configRawOriginalParsePending: Promise<void> | null;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
@@ -150,6 +152,8 @@ function createInitialConfigState(snapshot?: Partial<RuntimeConfigGatewaySnapsho
|
||||
configLoading: false,
|
||||
configRaw: "{\n}\n",
|
||||
configRawOriginal: "",
|
||||
configRawOriginalParsed: null,
|
||||
configRawOriginalParsePending: null,
|
||||
configValid: null,
|
||||
configIssues: [],
|
||||
configSaving: false,
|
||||
@@ -347,7 +351,7 @@ function applyConfigSnapshot(
|
||||
if (!preservePendingChanges) {
|
||||
state.configForm = cloneConfigObject(editableConfig ?? {});
|
||||
state.configFormOriginal = cloneConfigObject(editableConfig ?? {});
|
||||
state.configRawOriginal = rawFromSnapshot;
|
||||
setConfigRawOriginal(state, rawFromSnapshot);
|
||||
state.configFormDirty = false;
|
||||
state.configFormMode = "form";
|
||||
state.configDraftBaseHash = snapshot.hash ?? null;
|
||||
@@ -529,7 +533,7 @@ function serializeFormForSubmit(state: ConfigState): string {
|
||||
const sanitized = sanitizeRedactedFormForSubmit(
|
||||
form,
|
||||
state.configFormOriginal,
|
||||
state.configRawOriginal,
|
||||
state.configRawOriginalParsed,
|
||||
);
|
||||
return serializeConfigForm(sanitized);
|
||||
}
|
||||
@@ -558,7 +562,7 @@ function adoptConfigSetAck(state: ConfigState, submittedRaw: string, ackHash: st
|
||||
};
|
||||
state.configValid = true;
|
||||
state.configIssues = [];
|
||||
state.configRawOriginal = submittedRaw;
|
||||
setConfigRawOriginal(state, submittedRaw);
|
||||
if (parsed) {
|
||||
state.configFormOriginal = cloneConfigObject(parsed);
|
||||
}
|
||||
@@ -599,10 +603,19 @@ async function submitConfigChange(
|
||||
}
|
||||
const connectionEpoch = currentConfigConnectionEpoch(state);
|
||||
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
|
||||
// Claim busy before any await so a second click cannot slip past the busy
|
||||
// state while a JSON5 original parse settles; finally releases it.
|
||||
state[busyKey] = true;
|
||||
state.lastError = null;
|
||||
state.chatError = null;
|
||||
try {
|
||||
if (state.configRawOriginalParsePending) {
|
||||
// JSON5 originals parse asynchronously on first load; sanitize needs them.
|
||||
await state.configRawOriginalParsePending;
|
||||
if (!isCurrent()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
@@ -685,6 +698,11 @@ function teardownFlushConfigDraft(
|
||||
client: GatewayBrowserClient,
|
||||
baseHash: string,
|
||||
): void {
|
||||
// Must stay synchronous: page unload destroys the context before any
|
||||
// deferred work runs. If a JSON5 original parse is still pending, sanitize
|
||||
// passes placeholders through; the gateway restores restorable sentinels
|
||||
// (restoreRedactedValues) and rejects unrestorable ones, so the worst case
|
||||
// matches not flushing at all while the common case saves the draft.
|
||||
const raw = serializeFormForSubmit(state);
|
||||
void client.request("config.set", { raw, baseHash }).catch(() => undefined);
|
||||
}
|
||||
@@ -706,6 +724,16 @@ async function autoSaveConfig(
|
||||
}
|
||||
const connectionEpoch = currentConfigConnectionEpoch(state);
|
||||
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
|
||||
if (state.configRawOriginalParsePending) {
|
||||
// JSON5 originals parse asynchronously on first load; sanitize needs them.
|
||||
// Await only when pending: teardown flushes rely on a synchronous prefix.
|
||||
// Entry stays serialized across this await: runAutoSave's synchronous
|
||||
// in-flight check folds concurrent triggers into one trailing save.
|
||||
await state.configRawOriginalParsePending;
|
||||
if (!isCurrent() || !state.configFormDirty || state.configFormMode !== "form") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const submittedRaw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
@@ -869,7 +897,7 @@ async function lookupConfigSchemaPath(
|
||||
|
||||
function parseConfigRawDraft(raw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON5.parse(raw) as unknown;
|
||||
const parsed = parseJson5Text(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
@@ -878,6 +906,41 @@ function parseConfigRawDraft(raw: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the authoritative raw once at ingestion so submit-time sanitizing
|
||||
// stays synchronous and never races the lazy JSON5 parser. Submit paths await
|
||||
// configRawOriginalParsePending so a JSON5 config racing the first parser load
|
||||
// cannot bypass redaction sanitizing.
|
||||
function setConfigRawOriginal(state: ConfigState, raw: string) {
|
||||
state.configRawOriginal = raw;
|
||||
state.configRawOriginalParsePending = null;
|
||||
try {
|
||||
state.configRawOriginalParsed = asConfigRecord(parseJson5Text(raw));
|
||||
return;
|
||||
} catch {
|
||||
state.configRawOriginalParsed = null;
|
||||
}
|
||||
const pending = warmJson5()
|
||||
.then((json5) => {
|
||||
if (state.configRawOriginal !== raw || state.configRawOriginalParsePending !== pending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.configRawOriginalParsed = asConfigRecord(json5.parse(raw));
|
||||
} catch {
|
||||
state.configRawOriginalParsed = null;
|
||||
}
|
||||
})
|
||||
// Never-rejecting and self-clearing: submit gates await this promise, and
|
||||
// a failed chunk load must not wedge every later save of this state.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (state.configRawOriginalParsePending === pending) {
|
||||
state.configRawOriginalParsePending = null;
|
||||
}
|
||||
});
|
||||
state.configRawOriginalParsePending = pending;
|
||||
}
|
||||
|
||||
function mutateConfigForm(state: ConfigState, mutate: (draft: Record<string, unknown>) => void) {
|
||||
let base: Record<string, unknown>;
|
||||
if (state.configFormDirty && state.configFormMode === "raw") {
|
||||
@@ -986,6 +1049,9 @@ function updateConfigFormValue(state: ConfigState, path: Array<string | number>,
|
||||
}
|
||||
|
||||
function updateConfigRawValue(state: ConfigState, value: string) {
|
||||
// Raw drafts may carry JSON5 comments; warm the parser before any
|
||||
// mutateConfigForm/diff path needs it synchronously.
|
||||
void warmJson5().catch(() => undefined);
|
||||
state.configRaw = value;
|
||||
// A raw-text edit becomes the authoritative draft; without this,
|
||||
// serializeFormForSubmit would submit the stale form and drop raw edits.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJson5Text, warmJson5 } from "./json5-runtime.ts";
|
||||
|
||||
const COMMENTED = '// comment\n{\n "a": 1, // trailing\n}\n';
|
||||
|
||||
describe("json5 runtime boundary", () => {
|
||||
it("parses strict JSON on the fast path", () => {
|
||||
expect(parseJson5Text('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("parses JSON5 text once the module is warmed", async () => {
|
||||
await warmJson5();
|
||||
expect(parseJson5Text(COMMENTED)).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Lazy JSON5 boundary: strict JSON parses without the library, so json5 stays
|
||||
// out of the startup graph and loads only for config text that needs it.
|
||||
type Json5Module = { parse: (text: string) => unknown };
|
||||
|
||||
let json5: Json5Module | null = null;
|
||||
let json5Loading: Promise<Json5Module> | null = null;
|
||||
|
||||
export function isJson5Warm(): boolean {
|
||||
return json5 !== null;
|
||||
}
|
||||
|
||||
export function warmJson5(): Promise<Json5Module> {
|
||||
json5Loading ??= import("json5").then(
|
||||
(mod) => {
|
||||
json5 = mod.default;
|
||||
return json5;
|
||||
},
|
||||
(error: unknown) => {
|
||||
// Transient chunk-load failures must not pin a rejected loader forever;
|
||||
// the next warm retries the import.
|
||||
json5Loading = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return json5Loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-JSON fast path with a JSON5 fallback once the module is warmed.
|
||||
* Callers on the config surfaces warm the module before raw drafts can exist;
|
||||
* if a JSON5-only text races the warm-up, this throws like a parse failure and
|
||||
* the caller's existing invalid-draft handling applies until retry.
|
||||
*/
|
||||
export function parseJson5Text(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (jsonError) {
|
||||
if (json5) {
|
||||
return json5.parse(raw);
|
||||
}
|
||||
void warmJson5().catch(() => undefined);
|
||||
throw jsonError;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
// Control UI tests cover config behavior.
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import "../../styles.css";
|
||||
import type { ThemeMode, ThemeName } from "../../app/theme.ts";
|
||||
import { warmJson5 } from "../../lib/json5-runtime.ts";
|
||||
import { createConfigViewState, renderConfig, type ConfigProps } from "./view.ts";
|
||||
|
||||
describe("config view", () => {
|
||||
// The view module warms the lazy JSON5 parser on load; tests assert the
|
||||
// steady state where raw diffs parse synchronously.
|
||||
beforeAll(async () => {
|
||||
await warmJson5();
|
||||
});
|
||||
|
||||
const baseProps = () => ({
|
||||
raw: "{\n}\n",
|
||||
originalRaw: "{\n}\n",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Control UI view renders config screen content.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import JSON5 from "json5";
|
||||
import { html, nothing } from "lit";
|
||||
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
|
||||
import type { ConfigUiHints } from "../../api/types.ts";
|
||||
@@ -25,12 +24,12 @@ import {
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "../../components/config-form.shared.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import {
|
||||
analyzeConfigSchema,
|
||||
renderConfigForm,
|
||||
type ConfigSchemaAnalysis,
|
||||
} from "../../components/config-form.ts";
|
||||
import "../../components/tooltip.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { getLobsterdex, getLobsterdexEntries } from "../../components/lobster-dex.ts";
|
||||
import {
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
} from "../../components/settings-ui.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { ConfigAutoSaveStatus } from "../../lib/config/index.ts";
|
||||
import { isJson5Warm, parseJson5Text, warmJson5 } from "../../lib/json5-runtime.ts";
|
||||
import type { RealtimeTalkInputDevice } from "../chat/realtime-talk-input.ts";
|
||||
import { renderSettingsSelectRow } from "./settings-select-row.ts";
|
||||
import {
|
||||
@@ -54,6 +54,10 @@ import {
|
||||
COMMUNICATION_SETTINGS_TARGET_IDS,
|
||||
} from "./settings-targets.ts";
|
||||
|
||||
// The config editor is where JSON5 text first appears; warm the parser with
|
||||
// the page instead of racing the first raw-draft keystroke.
|
||||
void warmJson5().catch(() => undefined);
|
||||
|
||||
const TEXT_SCALE_LABELS: Record<TextScaleStop, string> = {
|
||||
90: "configView.textSizes.small",
|
||||
100: "configView.textSizes.default",
|
||||
@@ -746,8 +750,8 @@ function computeRawDiff(
|
||||
return viewState.rawDiffCache.diff;
|
||||
}
|
||||
try {
|
||||
const originalValue = JSON5.parse(original) as unknown;
|
||||
const currentValue = JSON5.parse(current) as unknown;
|
||||
const originalValue = parseJson5Text(original);
|
||||
const currentValue = parseJson5Text(current);
|
||||
if (
|
||||
!originalValue ||
|
||||
!currentValue ||
|
||||
@@ -766,7 +770,12 @@ function computeRawDiff(
|
||||
viewState.rawDiffCache = { original, current, diff };
|
||||
return diff;
|
||||
} catch {
|
||||
viewState.rawDiffCache = { original, current, diff: [] };
|
||||
// While the lazy JSON5 parser is still loading, a parse failure may be
|
||||
// transient; skip the cache so the next render retries instead of pinning
|
||||
// an empty diff for this text pair.
|
||||
if (isJson5Warm()) {
|
||||
viewState.rawDiffCache = { original, current, diff: [] };
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1799,6 +1808,13 @@ export function renderConfig(props: ConfigProps) {
|
||||
formMode === "raw" && hasRawChanges && viewState.rawDiffOpen
|
||||
? computeRawDiff(viewState, props.originalRaw, props.raw)
|
||||
: [];
|
||||
if (formMode === "raw" && hasRawChanges && viewState.rawDiffOpen && !isJson5Warm()) {
|
||||
// First diff open can race the lazy JSON5 parser; re-render when it lands
|
||||
// so the pending-changes list fills in instead of staying empty.
|
||||
void warmJson5()
|
||||
.then(() => requestUpdate())
|
||||
.catch(() => undefined);
|
||||
}
|
||||
// Includes the app updater: writes are suspended while it runs, so raw
|
||||
// Save/Discard must read busy instead of silently no-opping.
|
||||
const configBusy = props.loading || props.saving || props.applying || props.updating;
|
||||
|
||||
Reference in New Issue
Block a user