Skip to content

refactor: Angular v22 へアップデート + city-search を Signal Forms 統合に再整理 - #50

Merged
lacolaco merged 17 commits into
mainfrom
chore/ng-update-v22
Jun 6, 2026
Merged

lacolaco merged 17 commits into
mainfrom
chore/ng-update-v22

Conversation

@lacolaco

@lacolaco lacolaco commented Jun 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • @angular/cli @angular/core @angular/aria @angular/ssr @angular/cdk を v21.1 → v22.0 へ ng update で lockstep アップデート
  • v22 の @angular/aria Combobox 破壊的変更(developer preview → stable)に追随して city-search サンプルを移行し、Signal Forms と組み合わせる実装を整理
  • v22 @angular/build:unit-test の msw prebundle 衝突 (angular-cli#32523) を runnerConfig + 独自 vitest-base.config.ts で回避
  • Keyboard 操作のテストは ComboboxHarness + TestbedHarnessEnvironment 経由に書き直し(v22 の keyboardEventRelayEffect が @testing-library/angular の safeDetectChanges と衝突して NG0101 を出すため)

city-search の整理(v22 stable API へ)

Before (v21 dev preview) After (v22 stable)
ComboboxInput / ComboboxPopupContainer の 3 段構成 ngCombobox を <input> に直接、ngComboboxPopup + ngComboboxWidget の 2 段構成
combobox.close() メソッド expanded: ModelSignal<boolean> を直接書き換え
[(values)] (複数形) [(value)] (Listbox 値モデルは依然 V[])
自前の cityInputValue signal + setTimeout debounce + 手書き sync effect [formField]="searchForm.city" 一本で Signal Forms に統合
selectedCities → searchModel 反映を effect + untracked で同期 linkedSignal({ source, computation }) で宣言的に伝播
combobox.expanded.set(false) を effect で手続き的にクローズ isComboboxExpanded も同じ source の linkedSignal で選択時に自動 false
viewChild(Combobox).element.focus() searchForm.city().focusBoundControl()

最終的に effect / untracked / イベントハンドラ全廃で、コード量は半分以下に縮減。linkedSignal の source は両者で共通の selectedCity computed に集約し、「Listbox 選択を source にした派生」という関係性が宣言から読める形になっています。

ng update 自動投入の互換シム撤去

ng update migration が v21 挙動維持のために自動投入した opt-out も削除しました(CLAUDE.md "no backwards-compat shims" 規約):

  • withNoIncrementalHydration(): 当 repo に @defer (hydrate ...) は無く影響なし
  • tsconfig.app.json の nullishCoalescingNotNullable / optionalChainNotNullable suppress: 該当パターンが無く警告も出ない

v22 互換のための追加 devDep

パッケージ 理由
@angular/cdk ComboboxHarness テストで利用する CDK testing インフラ
@angular-eslint/builder angular-eslint v21 の builder が v22 CLI で resolve されないため明示依存
playwright @vitest/browser-playwright の peer dep が --legacy-peer-deps で脱落するのを防止

動作確認

  • npm test — 107/107 passed
  • npm run build — bundle 生成成功(ng update の自動 migration / withNoIncrementalHydration 削除後も SSR prerender 11 routes OK)
  • npm run lint — All files pass linting
  • code-critic Approved(初回時点。以降の linkedSignal/[formField] refactor は本 PR で完結)
  • npm start + 実 Chrome (chrome-devtools MCP) で動作確認:
    • Type "To" → suggestions に Tokyo / Toronto 表示
    • Click "Tokyo" → input が "Tokyo" に確定 / popup 自動クローズ
    • Submit → "You selected: Tokyo" 表示
    • Type "Pa" → ArrowDown + Enter で "Paris" 選択 / popup 自動クローズ
    • Escape で popup クローズ
    • Console error / warning ゼロ
    • MSW worker が dev server 経由でも稼働([MSW] GET /api/cities 200 OK)

🤖 Generated with Claude Code

lacolaco added 15 commits June 6, 2026 17:13
…r to v22

ng update が生成した自動 migration:
- app.config.ts: provideClientHydration に withNoIncrementalHydration() を追加し
  v22 既定の incremental hydration を opt out して従来挙動を維持
- tsconfig.app.json: extended diagnostics の nullishCoalescingNotNullable と
  optionalChainNotNullable を suppress(v22 で警告に昇格)

v22 対応のため依存追加:
- @angular/cdk: combobox harness で利用する CDK testing インフラに必要
- @angular-eslint/builder: angular-eslint v21 の builder が v22 CLI で
  解決されないため明示依存にして lint を復活
- playwright: @vitest/browser-playwright の peer dep が --legacy-peer-deps で
  脱落するのを防ぐため明示
…undle

v22 の @angular/build:unit-test は build の externalPackages: true から得た
implicitBrowser 一覧をそのまま vite の optimizeDeps.include に投入する。同時に
同パッケージを esbuild の external に並べるため、msw のように両側に載るものは
prebundle で「entry point "msw" cannot be marked as external」を吐く。

- angular.json: runnerConfig: true で vitest-base.config.ts を読み込ませる
- vitest-base.config.ts: optimizeDeps.exclude に msw を入れつつ、Angular が
  注入する optimizeDeps.include 配列からも configResolved で剥がす

参考: angular/angular-cli#32523
… API

v22 で @angular/aria の Combobox 構成が再設計された:
- ComboboxInput / ComboboxPopupContainer が廃止され、ngCombobox を input に
  直接適用する形に統一
- ComboboxPopup(ng-template)+ ComboboxWidget(popup 中身)でポップアップを
  組み立てる二段構成に分離
- close() が削除され、expanded model 経由で閉じる
- Listbox の二方向バインディングが [(values)] (複数形) から [(value)] へ統一
  (値は依然 V[] だがプロパティ名が変更)
- Listbox.selectionMode の既定が 'follow' になり、focus が動くだけで選択も
  追従するようになったため、明示的に selectionMode="explicit" を指定して
  click/Enter で確定する従来挙動を維持

加えて v22 Combobox は popup 展開中の Enter で preventDefault しないため、
form submit が暴発する。(keydown.enter) で展開中のみ抑止する。

テスト:
- Keyboard navigation 2 件は userEvent.keyboard 経由だと
  Combobox.keyboardEventRelayEffect が afterRenderEffect 内で同期 dispatchEvent
  し、@testing-library/angular の safeDetectChanges と衝突して
  NG0101: ApplicationRef.tick is called recursively を出す。
  公式の ComboboxHarness(@angular/aria/combobox/testing)は CDK の
  TestbedHarnessEnvironment 経由で sendKeys するため testing-library の
  ラッパーを通らず、CD 衝突を回避できる。
- sendKeys に複数キーを一度に渡すと keyboardEventRelay signal が同一 tick で
  上書きされ最後のキーしか listbox に届かないため、1 key ずつ送る。
- v22 では popup 展開時に先頭 option が自動 active になるため、テスト名と
  キー回数を v22 挙動に合わせて調整(Enter 単独で先頭選択、Arrow Down 1 回で
  2 番目に進む)
… v21 comparison comments

@angular/aria の KeyboardEventManager は default options で
preventDefault: true / stopPropagation: true を持つ。Combobox host の
.on('Enter', ...) は個別 options を省略しているのでデフォルトが適用され、
Enter は Combobox 内部で既に preventDefault される。よって追加の
(keydown.enter) ハンドラは不要。

併せて v21 挙動との差分に言及していたコメントを除去する。
v22 で安定版になった現行 API の挙動として読めば十分。
Combobox/Listbox の状態テストに ComboboxHarness を使うのは正規の選択肢で、
non-obvious な workaround ではない。経緯コメントは不要。
ng update が pre-v22 挙動を保つために自動投入していた opt-out 設定を撤去:

- withNoIncrementalHydration(): プロジェクトに @defer (hydrate ...) は無いため
  v22 デフォルトのままで影響なし。シムは単なる挙動凍結。
- tsconfig.app.json の nullishCoalescingNotNullable /
  optionalChainNotNullable suppress: 現コードに該当パターンは無く、
  警告が出ない以上 suppress も不要。

build / 107 tests / lint いずれも pass。
…f imperative set

selectionMode が 'follow' のままだった頃の暫定回避として viewChild + 手続き的
expanded.set(false) を使っていたが、selectionMode="explicit" を入れた現在は
auto-select に伴う暴れも無くなった。"isExpanded" signal と [(expanded)] の
二方向バインディングに戻す。
…Field]

[formField] ディレクティブで input を searchForm.city フィールドに直接バインド
する。これに伴い手続き的なコードが大きく整理された:

- cityInputValue signal を削除(searchForm.city().value() が source of truth)
- debounce 用の effect + setTimeout を削除
- onComboboxEnter / onInputEnter / commit ハンドラを削除
- 選択値を反映する effect は selectedCities → searchModel.city への単純な
  同期 1 つだけに

[formField] 経由なら他の examples(location-select 等)と同じ Signal Forms
integration パターンに揃う。
選択後 popup が消えるのは suggestionItems computed が選択値に一致する候補を
空配列で返し @if が描画を落とすため。combobox の expanded 状態をユーザー側で
管理する必要はなく、Escape / blur での自動クローズも combobox 内部の
closePopupOnBlurEffect / Escape ハンドラが担う。
… propagation

listbox の選択 → form model 反映 と popup の自動クローズを effect + untracked
で書いていたのを、linkedSignal の source-based 派生に置き換える。"selection
を source にしつつ、他経路([formField] や combobox の two-way)からの
書き込みも受け付ける" という意図がそのまま型で表現でき、effect / untracked /
イベントハンドラなしで宣言的に同期できる。

- searchModel: selectedCities[0] を source、選択時は { city } で上書き、それ
  以外は previous.value 保持([formField] の書き込みを尊重)
- isExpanded: 同じ source、選択時は false にリセット、それ以外は previous.value
  保持(combobox の two-way 書き込みを尊重)

suggestionItems の "完全一致なら隠す" ワークアラウンドも不要に
(isExpanded が選択時に false になり popup の ng-template ごと描画されない)。
無効入力時のフォーカス移動を viewChild(Combobox).element.focus() ではなく
searchForm.city().focusBoundControl() に置き換える。Signal Forms 経由で
[formField] でバインドされた control が自動で focus される。viewChild ごと
撤去できた(location-select.ts と同じパターン)。

linkedSignal の型は computation の戻り値型注釈で十分推論できるので、
ジェネリクスパラメータも省略。
httpResource / onSubmit ともに searchForm.city().value() で field 経由の
読み出しに揃える。searchModel を直接読むのと結果は同じだが、Signal Forms の
field アクセス API を示すサンプルとして読みやすい。あわせて suggestions を
suggestionItems より前に宣言して top-down に読めるよう並び替え。
searchModel / isExpanded の linkedSignal で同じ selectedCities[0] を読んで
いたのを共通の selectedCity computed に抽出。"両者は同じ source に対する
別の派生" という関係性が型と宣言から読める。
"isExpanded" だけだと述語の対象(何が expanded か)が読めない。combobox の
expanded model に対応する signal なので isComboboxExpanded にする。
@lacolaco lacolaco changed the title chore: Angular v22 へアップデート refactor: Angular v22 へアップデート + city-search を Signal Forms 統合に再整理 Jun 6, 2026
lacolaco added 2 commits June 6, 2026 19:35
CI の `npm ci` で @angular-eslint/[email protected] が @angular/[email protected] と peer
dep 競合して install 失敗するため、.npmrc で legacy-peer-deps を有効化して
local / CI を統一する。angular-eslint が v22 対応リリースを出したら撤去できる。
@angular/build:unit-test の Vitest runner は isolate: false が既定で、複数
test ファイルが同じ browser context を共有する。ファイルごとの afterAll で
worker.stop() を呼ぶと、他ファイルの test 実行中に MSW が無効化されて
"Could not verify ..." などの onError 経路に落ちる(CI で再現)。

ブラウザコンテキスト自体が test run 終了時に破棄されるので明示停止は不要。
@lacolaco
lacolaco merged commit 9c9b8a0 into main Jun 6, 2026
1 check passed
@lacolaco
lacolaco deleted the chore/ng-update-v22 branch June 6, 2026 11:56
lacolaco added a commit that referenced this pull request Jun 6, 2026
PR #50 で Angular v22 にアップデート済みなので README 冒頭の "Angular 21" /
"experimental" の記述を v22 として書き直す。
lacolaco added a commit that referenced this pull request Jun 6, 2026
PR #50 で Angular v22 にアップデート済みなので README 冒頭の "Angular 21" /
"experimental" の記述を v22 として書き直す。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant