diff --git a/backend/api/routers/profiles.py b/backend/api/routers/profiles.py index a4483beb..80f497aa 100644 --- a/backend/api/routers/profiles.py +++ b/backend/api/routers/profiles.py @@ -73,6 +73,17 @@ async def create_profile( raise ValueError("not an object") except ValueError: raise HTTPException(status_code=422, detail="vd_states must be a JSON object") + # Root-cause close for #983: a design profile must never be PERSISTED + # with a partial vd_states shape, regardless of which client (older + # frontend build, hand-edited payload, third-party API caller) created + # it — a missing category key crashes DesignMethodPanel's render on + # every future client that selects this profile. CATEGORY_ORDER is the + # same single source of truth the frontend's CATEGORIES keys mirror + # (core/describe_voice.py), so this can't drift from the picker. + from core.describe_voice import CATEGORY_ORDER + for _cat in CATEGORY_ORDER: + parsed.setdefault(_cat, "Auto") + vd_states = _json.dumps(parsed) # An all-Auto design (every category left on "Auto") yields an empty # instruct — that's still a valid, saveable voice: synthesis falls back # to neutral instruct-only conditioning (see generation.py design path). diff --git a/frontend/src/components/clone/DesignMethodPanel.jsx b/frontend/src/components/clone/DesignMethodPanel.jsx index 57c37ba8..26a863b2 100644 --- a/frontend/src/components/clone/DesignMethodPanel.jsx +++ b/frontend/src/components/clone/DesignMethodPanel.jsx @@ -147,6 +147,13 @@ export default function DesignMethodPanel({ {Object.entries(CATEGORIES).map(([key, options]) => { const many = options.length > 6; const optLabel = (val) => { + // #983: a profile/localStorage-restored vdStates can carry a + // partial shape (missing category keys) — val is then undefined + // here even though the 'Auto' check above only catches the + // literal sentinel. Guard before .replace() rather than crash; + // 'Auto' matches how the rest of the component (the ternary + // above, the chip/select fallbacks) treats an unset category. + if (typeof val !== 'string' || !val) return 'Auto'; const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`; const tl = t(tKey); return tl !== tKey ? tl : val; @@ -180,9 +187,13 @@ export default function DesignMethodPanel({ aria-label={t(`clone.cat_${key}`)} > {options.map((opt, i) => { - const optTKey = `clone.opt_${opt.replace(/[ -]/g, '_')}`; + // `opt` is always a hardcoded CATEGORIES string today, + // never undefined — guarded anyway for consistency with + // the identical pattern above (#983). + const safeOpt = typeof opt === 'string' && opt ? opt : 'Auto'; + const optTKey = `clone.opt_${safeOpt.replace(/[ -]/g, '_')}`; const optTl = t(optTKey); - const optLabel = optTl !== optTKey ? optTl : opt; + const optLabel = optTl !== optTKey ? optTl : safeOpt; const checked = vdStates[key] === opt; // Roving tabindex: the checked chip is the group's // single tab stop (first chip if nothing matches). diff --git a/frontend/src/components/clone/DesignMethodPanel.test.jsx b/frontend/src/components/clone/DesignMethodPanel.test.jsx new file mode 100644 index 00000000..936e5e71 --- /dev/null +++ b/frontend/src/components/clone/DesignMethodPanel.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import DesignMethodPanel from './DesignMethodPanel'; + +// #983: "Cannot read properties of undefined (reading 'replace')" — the +// identity panel crashed whenever vdStates was missing one of the 6 +// CATEGORIES keys (a design profile saved by an older/foreign client, or a +// stale localStorage shape). The label helper called `val.replace(...)` on +// an undefined category value. This regression-tests the render guard added +// to DesignMethodPanel.jsx directly, independent of the upstream data-shape +// fixes in useProfiles.js / useAppData.js / profiles.py. + +// A minimal i18next-compatible mock: returns the defaultValue if given, else +// echoes the key back (mirrors i18next's behavior for a missing translation, +// which is what `optLabel`'s `tl !== tKey` check relies on). +const t = (key, opts) => opts?.defaultValue ?? key; + +function setup(vdStates, props = {}) { + return render( + , + ); +} + +describe('DesignMethodPanel — #983 partial vdStates crash', () => { + it('does not throw when vdStates is missing 5 of the 6 CATEGORIES keys', () => { + // Only Gender is set — Age, Pitch, Style, EnglishAccent, ChineseDialect + // are all undefined, exercising both the chip-based and