diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs index b691f7652e53..4c20d03ce229 100644 --- a/scripts/check-control-ui-performance.mjs +++ b/scripts/check-control-ui-performance.mjs @@ -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, diff --git a/ui/src/app/custom-theme-jitless.test.ts b/ui/src/app/custom-theme-jitless.test.ts deleted file mode 100644 index cbe1c679aae2..000000000000 --- a/ui/src/app/custom-theme-jitless.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/ui/src/app/custom-theme.ts b/ui/src/app/custom-theme.ts index 71f38474a039..34a4849e1cd3 100644 --- a/ui/src/app/custom-theme.ts +++ b/ui/src/app/custom-theme.ts @@ -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; -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(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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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; - 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 | undefined): ThemeTokenMap | null { +function normalizeStoredTokenMap(value: Record | undefined): ThemeTokenMap | null { if (!value || typeof value !== "object") { return null; } @@ -321,8 +268,8 @@ function normalizeStoredTokenMap(value: Record | undefined): The } function resolveModeVar( - theme: Record, - shared: Record | undefined, + theme: Record, + shared: Record | undefined, key: string, fallback?: string, ) { @@ -344,8 +291,8 @@ function resolveModeVar( function normalizeModeTokenMap( mode: "light" | "dark", - theme: Record, - shared: Record | undefined, + theme: Record, + shared: Record | 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, ): 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), }; } diff --git a/ui/src/lib/config-form-utils.node.test.ts b/ui/src/lib/config-form-utils.node.test.ts index 13c3031c2759..e13d62bbf236 100644 --- a/ui/src/lib/config-form-utils.node.test.ts +++ b/ui/src/lib/config-form-utils.node.test.ts @@ -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", () => { diff --git a/ui/src/lib/config-form-utils.ts b/ui/src/lib/config-form-utils.ts index 36d4d971495f..0ff201025afb 100644 --- a/ui/src/lib/config-form-utils.ts +++ b/ui/src/lib/config-form-utils.ts @@ -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(value: T): T { return structuredClone(value); @@ -93,19 +92,11 @@ function sanitizeRedactedValue(params: { export function sanitizeRedactedFormForSubmit( form: Record, originalForm: Record | null | undefined, - originalRaw: string, + parsedOriginalRaw: Record | null, ): Record { - 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; } diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts index 7d046dab046b..663bcdbb5758 100644 --- a/ui/src/lib/config/index.ts +++ b/ui/src/lib/config/index.ts @@ -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 | null; + configRawOriginalParsePending: Promise | null; configValid: boolean | null; configIssues: unknown[]; configSaving: boolean; @@ -150,6 +152,8 @@ function createInitialConfigState(snapshot?: Partial 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 | null { try { - const parsed = JSON5.parse(raw) as unknown; + const parsed = parseJson5Text(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : null; @@ -878,6 +906,41 @@ function parseConfigRawDraft(raw: string): Record | 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) => void) { let base: Record; if (state.configFormDirty && state.configFormMode === "raw") { @@ -986,6 +1049,9 @@ function updateConfigFormValue(state: ConfigState, path: Array, } 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. diff --git a/ui/src/lib/json5-runtime.test.ts b/ui/src/lib/json5-runtime.test.ts new file mode 100644 index 000000000000..4e9d6f801c7e --- /dev/null +++ b/ui/src/lib/json5-runtime.test.ts @@ -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 }); + }); +}); diff --git a/ui/src/lib/json5-runtime.ts b/ui/src/lib/json5-runtime.ts new file mode 100644 index 000000000000..e9e07ee13613 --- /dev/null +++ b/ui/src/lib/json5-runtime.ts @@ -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 | null = null; + +export function isJson5Warm(): boolean { + return json5 !== null; +} + +export function warmJson5(): Promise { + 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; + } +} diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index 3a9725fb3a58..50cf4c06a007 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -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", diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index e28a12f37cfb..1922cfd2ba69 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -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 = { 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;