設定画面の <select> に、保存されている値の <option> が無いとします。そのまま保存ボタンを押すと、先頭の選択肢の値が送信されて保存されます。利用者は何も選んでいません。自作の WordPress プラグイン Rapls AI Chatbot で、提供が終わった AI モデルを一覧から外したときに起きていました。
Chromium 141(Playwright)で、6通りを測りました。保存値は claude-sonnet-4 で、一覧には無い想定です。
| 状態 | selectedIndex | 送信される値 |
|---|---|---|
| A. 保存値の option が無い | 0 | 先頭の claude-opus-4-6
|
B. A の先頭を disabled にした |
1 | 2番目の claude-sonnet-4-6
|
C. 先頭に value="" の選択肢がある |
0 | 空文字 |
D. 保存値の option を1行足して selected
|
3 |
claude-sonnet-4(保存値のまま) |
E. A を size="4" のリストボックスにした |
-1 | 送信されない |
F. A の表示後に、JS で value に一覧外の値を代入 |
-1 | 送信されない |
先に断っておくと、A と B は HTML の仕様どおりの動きです。HTML Living Standard の selectedness setting algorithm には、multiple が無く、表示サイズが1で、選択された option が1つも無いとき、最初の disabled でない option を選択状態にする、と書いてあります。value の setter については、一致する option が無いと、表示サイズが1でも何も選択されない状態になりうる、ともあります。ここで書くのは、それがプラグインの設定画面で何を起こしたかと、6通りを実際に送信して測った結果です。
何が起きたか
Anthropic は、Claude Sonnet 4 を2026年6月15日に、Opus 4.1 を8月5日に提供終了しました。プラグインは 1.20.5 で、この2つをモデルの一覧から外しました。
そのときの設定画面のテンプレートは、一覧の配列をそのまま option にして、保存値と一致するものに selected を付けるだけでした。Claude の欄も OpenAI の欄も同じ形で、下は OpenAI の欄です。
<select name="raplsaich_settings[openai_model]" id="raplsaich-openai-model">
<?php foreach ($openai_provider->get_available_models() as $value => $label): ?>
<option value="<?php echo esc_attr($value); ?>"
<?php selected($settings['openai_model'] ?? 'gpt-4o-mini', $value); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
保存値が一覧から消えると、どの option にも selected が付きません。ブラウザは先頭の option を選んだ状態で表示します。Claude の一覧の先頭は Opus 4.6 でした。
設定画面には、モデル以外の項目もたくさんあります。別の項目を直して保存を押すと、モデルも Opus 4.6 で保存されます。見た目は「Opus 4.6 が選ばれている」ので、利用者が気づく手がかりもありません。
実際に POST を見た
FormData で数えたあと、実際にフォームも送信して、リクエストの本文を取りました。
A POST body = x=1&model=claude-opus-4-6
F POST body = x=1
A は、先頭の値がそのまま送られます。F は、model というキーそのものが本文にありません。
F の形は、受け取る側の作りで結果が変わります。キーが無いことを「変更なし」と扱えば保存値は残り、既定値で埋める作りなら既定値に変わります。どちらにしても、画面に表示されている値と保存される値が一致しません。
B と C も、守りにはならない
B は、先頭を disabled にしても、次の有効な option が選ばれるだけです。
C は、先頭に「選択してください」のような空の選択肢を置く形です。送信される値は空文字になります。受け取る側で空文字を弾けば、先頭のモデルに変わることは防げます。ただ、保存値が一覧に無いことは、画面のどこにも出ません。
D:保存値の option を残す
1.20.5 では Claude だけ、1.21.0 で OpenAI と Gemini も同じ形に直しました。表示する前に、保存値が一覧に無ければ1行足します。
$openai_saved_model = $settings['openai_model'] ?? 'gpt-4o-mini';
$openai_retirement = RAPLSAICH_OpenAI_Provider::retirement($openai_saved_model);
$openai_shown_model = $openai_retirement ? $openai_retirement['to'] : $openai_saved_model;
$openai_model_options = $openai_provider->get_available_models();
if ($openai_shown_model !== '' && !isset($openai_model_options[$openai_shown_model])) {
$openai_model_options[$openai_shown_model] = $openai_shown_model;
}
すでに提供終了したモデルが保存されているときは、送信時に後継へ差し替えているので、その後継を選んだ状態で表示し、そのことを下に書きます。一覧から外しただけで、まだ動いているモデルなら、それ自身の option を足して残します。どちらでも、保存を押しただけで値が変わることはありません。
JS の側は、とっくに同じことをしていた
直すときに、設定画面の JavaScript を読んで気づきました。モデルの一覧を API から取り直すボタンがあって、その処理は保存値が新しい一覧に無いとき、(saved) と付けた option を足していました。
if (savedExists) {
$select.val(savedValue);
} else if (savedValue) {
// Saved model not in new list - add it with (saved) label
var $savedOption = $('<option>')
.val(savedValue)
.text(savedValue + ' ' + (i18n.modelSaved || '(saved)'));
$select.prepend($savedOption);
$select.val(savedValue);
}
一覧を取り直したときだけは D の形になっていて、PHP で最初に描く画面は A の形のままでした。同じ select を作る入り口が2つあって、片方にしか守りが入っていなかったことになります。
$select.val(savedValue) を、option を足さずに呼んでいたら F になっていたはずです。jQuery の .val() も、一致する option が無ければ selectedIndex を -1 にします。
保存値が一覧に無いときの扱い
- 一覧を描く前に、保存値が一覧にあるかを確かめる。無ければ option を足すか、少なくとも画面にそう書く
- JS で
valueを代入するときは、代入のあとにselectedIndexが -1 になっていないかを見る。-1 ならキーごと送信されない - 一覧を作る入り口が複数あるなら、全部で同じ扱いにする
あなたの設定画面に、保存値が選択肢から消えうる <select> はありませんか。
手元で試すコマンド
Playwright が入っていれば、そのまま動きます。
const { chromium } = require('playwright');
const opts = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
const o = (v, extra = '') => `<option value="${v}"${extra}>${v}</option>`;
const cases = {
'A 保存値が一覧に無い': opts.map(v => o(v)).join(''),
'B 先頭が disabled': o('claude-opus-4-6', ' disabled') + opts.slice(1).map(v => o(v)).join(''),
'C 先頭に空の選択肢': '<option value="">選択してください</option>' + opts.map(v => o(v)).join(''),
'D 保存値を1行足して selected': opts.map(v => o(v)).join('') + o('claude-sonnet-4', ' selected'),
'E size=4(リストボックス)': opts.map(v => o(v)).join(''),
'F JS で value に一覧外を代入': opts.map(v => o(v)).join(''),
};
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
console.log('Chromium', b.version());
for (const [name, html] of Object.entries(cases)) {
const size = name.startsWith('E') ? ' size="4"' : '';
await p.setContent(`<form id="f"><select name="model" id="s"${size}>${html}</select></form>`);
if (name.startsWith('F')) {
await p.evaluate(() => { document.getElementById('s').value = 'claude-sonnet-4'; });
}
const r = await p.evaluate(() => {
const s = document.getElementById('s');
const fd = new FormData(document.getElementById('f'));
return { selectedIndex: s.selectedIndex, value: s.value, sent: fd.has('model') ? fd.get('model') : '(送信されない)' };
});
console.log(name, JSON.stringify(r));
}
await b.close();
})();
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/