From 8520b84b68c4eaa68e8091adfa0ef1f8b8f76591 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Wed, 8 Jul 2026 04:34:14 +0530 Subject: [PATCH] fix(clone): voice-design panel no longer crashes on a partial vd_states shape (#983) (#995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash: DesignMethodPanel's optLabel() called val.replace(...) on an undefined category value — a regression from f678e33, which swapped a safe plain template literal for an i18n lookup that assumes vdStates[key] is always a string. Both occurrences (the label kicker and the chip list) are now guarded, falling back to 'Auto' the same way the rest of the component treats an unset category. Root cause: vdStates could actually go partial in real usage. Selecting a design profile (useProfiles.js) or restoring legacy localStorage state (useAppData.js) applied the backend/stored vd_states object as-is, with no check that all 6 CATEGORIES keys were present — so an older client, hand-edited payload, or partial API write reproduced the crash on selection. Both call sites now run the restored object through mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper already used for the "describe your voice" path, which fills any missing/unknown category with 'Auto'. useAppData.js also gained the typeof === 'object' guard useProfiles.js already had. Closes the class at the source: POST /profiles now completes vd_states against CATEGORY_ORDER (core/describe_voice.py, the same list the frontend's CATEGORIES mirrors) before persisting, so a design profile can never be *saved* with an incomplete shape regardless of which client wrote it — updated two existing tests whose fixtures asserted the old (partial) persisted shape. Regression tests: DesignMethodPanel render test with a partial vdStates input, a mergeDescribedAttrs unit test for the exact partial shape from the issue, and a backend test asserting POST /profiles fills all 6 keys. Co-authored-by: mergetest Co-authored-by: Claude Sonnet 5 --- backend/api/routers/profiles.py | 11 +++ .../components/clone/DesignMethodPanel.jsx | 15 +++- .../clone/DesignMethodPanel.test.jsx | 68 +++++++++++++++++++ frontend/src/hooks/useAppData.js | 8 ++- frontend/src/hooks/useProfiles.js | 9 ++- tests/frontend/describeVoice.test.mjs | 16 +++++ tests/test_profile_design_save_decouple.py | 6 +- tests/test_profile_unification.py | 31 ++++++++- 8 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/clone/DesignMethodPanel.test.jsx 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