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:
Peter Steinberger
2026-07-18 12:36:46 +01:00
committed by GitHub
parent 684ae080d4
commit f98bcb7fa7
10 changed files with 218 additions and 175 deletions
+2 -2
View File
@@ -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,
-41
View File
@@ -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
View File
@@ -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),
};
}
+13 -27
View File
@@ -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", () => {
+4 -13
View File
@@ -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;
}
+71 -5
View File
@@ -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.
+16
View File
@@ -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 });
});
});
+44
View File
@@ -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;
}
}
+8 -1
View File
@@ -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",
+21 -5
View File
@@ -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;