Skip to content

Commit 49558f4

Browse files
committed
fix(dashboard): guard ModelSelect against non-string value mid-render
`ModelSelect`'s `value` prop is typed `string | undefined`, but it comes from `getNestedValue(formData(), path)` which returns `unknown` — the `as string | undefined` cast at call sites was a typed lie. When the ConfigForm prop `content` switched between Pi and OpenCode tabs and formData updated asynchronously via `createEffect`, JSX's fine-grained reactivity could observe `props.value` as truthy on the outer ternary (line 80) while the inner `.substring()` calls (lines 82-83) re-read it as undefined within the same render, crashing with `undefined is not an object (evaluating 'e.value.substring')`. Triggered by clicking Pi config → OpenCode config in the ConfigEditor's tab strip. Memoizes a normalized string view of `props.value` via `createMemo`, falling back to "" for non-string or undefined values, and routes every read of the value through that memo so the ternary and the inner `.substring()` calls share one consistent snapshot per render. Dashboard typecheck/build clean.
1 parent 6c85921 commit 49558f4

1 file changed

Lines changed: 16 additions & 8 deletions

File tree

packages/dashboard/src/components/ConfigEditor/ModelSelect.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,19 @@ export default function ModelSelect(props: ModelSelectProps) {
6060
stopListening();
6161
};
6262

63+
// Memoize a normalized string view of props.value so JSX reads can't
64+
// see a stale "truthy" outer ternary while the inner .substring() call
65+
// re-evaluates against a now-undefined / non-string value.
66+
// Reproduces in dashboard ConfigEditor when switching Pi → OpenCode
67+
// config tabs: createEffect updates formData asynchronously, so the
68+
// ternary at line 80 and the inner reads at 82-83 could observe
69+
// different snapshots of props.value within the same render.
70+
const valueStr = createMemo(() => (typeof props.value === "string" ? props.value : ""));
71+
6372
const displayValue = () => {
64-
if (!props.value) return props.placeholder ?? "— Use fallback chain —";
65-
// Show just model name after provider/
66-
const slash = props.value.indexOf("/");
67-
return slash >= 0 ? props.value : props.value;
73+
const v = valueStr();
74+
if (!v) return props.placeholder ?? "— Use fallback chain —";
75+
return v;
6876
};
6977

7078
const providerOf = (model: string) => {
@@ -76,11 +84,11 @@ export default function ModelSelect(props: ModelSelectProps) {
7684
<div class="model-select" ref={containerRef}>
7785
{/* Trigger button */}
7886
<button class="model-select-trigger" onClick={openDropdown} type="button">
79-
<span class={`model-select-value ${!props.value ? "placeholder" : ""}`}>
80-
{props.value ? (
87+
<span class={`model-select-value ${!valueStr() ? "placeholder" : ""}`}>
88+
{valueStr() ? (
8189
<>
82-
<span class="model-select-provider">{providerOf(props.value)}/</span>
83-
{props.value.substring(props.value.indexOf("/") + 1)}
90+
<span class="model-select-provider">{providerOf(valueStr())}/</span>
91+
{valueStr().substring(valueStr().indexOf("/") + 1)}
8492
</>
8593
) : (
8694
displayValue()

0 commit comments

Comments
 (0)