0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

select に保存値の option が無いと、保存を押すだけで先頭の値に変わります【WordPress の設定画面で起きた記録】

0
Posted at

設定画面の <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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?