fix(native): optimistic tabs and searchable projection lock - #184
Conversation
Avoid flashing back to the stale DOM selected tab after a native tap. Co-authored-by: Cursor <cursoragent@cursor.com>
Freeze Web layout and Cap keyboard resize for the search session, and keep optimistic tab selection on the searchable controller. Co-authored-by: Cursor <cursoragent@cursor.com>
| guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return } | ||
| if let controlId = controls.first(where: { $0.value === tabBar })?.key { | ||
| pendingTabSelections[controlId] = .start(itemId) | ||
| } |
There was a problem hiding this comment.
🟡 拒否されたタブが選択表示のまま残る
Web 側が選択を拒否すると、最初の再同期は通常タイムアウト前に終わります。期限到達時の再同期がないため、別の更新まで誤ったタブが選択表示され続けます。
Learn more
保留選択の期限は、次にスナップショットが適用されたときだけ評価されます。このタップは JS 側で lastSnapshot を空にして一度だけ再同期しますが、その更新は通常 1 秒の期限より前です。DOM が以前の選択を返しても保留は維持され、その後に更新を起こすタイマーはありません。検索付きタブも resolveOrdinarySelection で同じ方式を使います。
Example: 「Home」が選択中に「Library」をタップし、アプリが遷移を拒否したとします。100 ms 後のスナップショットは引き続き「Home」ですが、期限前なので UIKit は「Library」を維持します。1 秒後にも処理は走らず、次の DOM 更新まで「Library」が選択中に見えます。
Recommended fix: PendingSelection.start ごとに期限時の再同期を予約し、その時点で現在の DOM 選択へ戻してください。新しいタップやコントロール削除では古い予約をキャンセルし、通常タブと ShellSearchController の双方に同じ期限処理を適用してください。
Was this helpful? React with 👍 or 👎 to provide feedback.
| heldKeyboardResize = 'native'; | ||
| void (async () => { | ||
| try { | ||
| const current = await keyboard.getResizeMode?.(); | ||
| if (typeof current?.mode === 'string') heldKeyboardResize = current.mode; | ||
| } catch { | ||
| /* keep default restore target */ | ||
| } | ||
| try { | ||
| await setResizeMode({ mode: 'none' }); |
There was a problem hiding this comment.
🟡 キーボードのリサイズ設定が none に固定される
モード取得中に検索を閉じると、解放後に古い非同期処理が none を設定します。以後の Web キーボード表示で期待したリサイズが行われません。
Learn more
検索開始時の処理は、先に heldKeyboardResize を設定してから getResizeMode() を待ちます。待機中に検索を閉じると、releaseKeyboardResize() は復元して状態を null にします。しかし古い開始処理はその後も続き、取得結果を再保存して none を設定します。以後は検索が非アクティブなので、対応する解放処理が再度呼ばれる保証はありません。
Example: getResizeMode() に 200 ms かかる端末で検索を開き、すぐ閉じます。解放処理が native を復元した後、200 ms 時点で古い処理が none に変更します。通常の Web 入力を開いても WebView は設定済みの方式でリサイズされません。
Recommended fix: 保持処理に世代トークンまたはキャンセル可能な Promise 状態を追加してください。各 await 後に同じ保持世代がまだ有効で、アクティブな投影が存在することを確認してから heldKeyboardResize と none を適用してください。途中で解放された場合は取得した元モードを変更せず、遅れて none が適用済みなら直ちに元へ戻してください。
Was this helpful? React with 👍 or 👎 to provide feedback.
| if active { | ||
| if lockedWebFrame == nil { | ||
| lockedWebFrame = surface.bounds.isEmpty ? webFrame : surface.frame | ||
| } | ||
| if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame } |
There was a problem hiding this comment.
🟡 画面回転後も検索面が旧サイズに固定される
検索中に回転すると、新しい画面フレームを無視して回転前のフレームを再設定します。検索 UI と操作領域が横画面でクリップまたは位置ずれします。
Learn more
ロック対象はキーボードによる WebView の縮小だけでなく、検索中に届くすべての webFrame です。端末回転では親ビューと WebView の寸法が変わりますが、保存済みの portrait フレームが再設定されます。surface の autoresizing が先に新しい寸法へ追従しても、この代入がそれを元へ戻します。
Example: 390×844 の portrait で検索を開き、844×390 の landscape に回転します。更新は 844×390 の webFrame を渡しますが、surface.frame は 390×844 に戻されます。右側が覆われず、縦方向は親ビュー外へはみ出します。
Recommended fix: キーボードによる一時的な縮小と、回転・ウィンドウサイズ変更を区別してください。画面の基準サイズが変わった場合はロックを新しい全画面フレームへ更新し、検索内部の UIKit レイアウトも再計算してください。キーボード表示中の高さ変化だけを無視する方式が安全です。
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (binding.active && existing && isCurrent(existing) && existing.last) { | ||
| candidate.control.search = { | ||
| ...existing.last, | ||
| active: true, | ||
| available: true, | ||
| focused: binding.focused, | ||
| value: existing.bar.value ?? '', | ||
| placeholder: existing.bar.placeholder ?? '', | ||
| disabled: existing.bar.disabled, | ||
| editSequence: existing.editSequence, | ||
| valueVersion: existing.valueVersion, | ||
| }; | ||
| existing.last = candidate.control.search; | ||
| candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer]; | ||
| candidate.actions.set(existing.last.trigger.id, binding.trigger); | ||
| const back = binding.footer.querySelector<HTMLIonButtonElement>('ion-buttons[slot=start] ion-button'); | ||
| if (back) candidate.actions.set(existing.last.closeId, back); | ||
| continue; |
There was a problem hiding this comment.
🟡 無効化された閉じるボタンで検索を終了できない
検索中のキャッシュ経路は、閉じるボタンの適格性検査を省略します。ボタンが動的に無効化されるとネイティブ UI が残り、閉じる操作が機能しません。
Learn more
通常経路は閉じるボタンの存在、固定ツールバー配置、除外状態、disabled、色、fill を検証します。アクティブ時の早期 continue はそれらを再確認せず、以前の ID と action を再利用します。ネイティブの閉じる操作は activate から元要素の click() を呼ぶため、無効なボタンではアプリの Leave ハンドラーが実行されません。
Example: 検索中にフォーム送信を開始し、アプリが戻るボタンへ disabled=true を設定します。次のスナップショットでも検索は利用可能として残ります。ユーザーが閉じる操作をしても無効ボタンの click は処理されず、検索画面から戻れません。
Recommended fix: アクティブ時も、レイアウト計測を必要としない構造・状態の適格性を毎回検証してください。閉じるボタンや FAB が欠落、無効、除外、または unsupported になった場合は active:false, available:false を返してネイティブ検索を退役させてください。トリガーの通常時の可視性だけは UISearchTab の morph と区別して扱ってください。
Was this helpful? React with 👍 or 👎 to provide feedback.
| const keyboardPlugin = (): KeyboardPlugin | undefined => | ||
| ( | ||
| doc.defaultView as Window & { | ||
| Capacitor?: { Plugins?: { Keyboard?: KeyboardPlugin } }; | ||
| } | ||
| )?.Capacitor?.Plugins?.Keyboard; |
| if (doc.hidden || overlayOpen()) return []; | ||
| if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) | ||
| return []; |
Playwright test resultsDetails
Failed testschromium › animation.spec.ts › Animation Tests › runs and completes the iOS page transition Flaky testschromium › native-ui-shell-edge.spec.ts › Push and repeated back match Web with collapsing Web headers |
|
📊 Ionic 9 Playwright Test Report View the detailed Ionic 9 report: https://rdlabo-dev.github.io/ionic-theme-ios27/pr-184/ Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run. |
Register searchable earlier and keep ShellSearchController across Index/Album with ordinary UITabBar + FAB while idle, so navigation does not demote to Web or shift tab layout before UISearchTab opens. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep ionFocus tied to the native focus phase, expire optimistic tab selection without a later apply, re-lock search width on rotation, and cancel stale Cap resize / close-ineligible active caches. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Wire the adaptive Sass/JS selection from the docs so the demo follows iOS 26 or 27 styles and animations, while Playwright keeps an iOS 27-only stylesheet so Chromium screenshots stay on the main baseline. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep capability-based iOS 26/27 loading in global.scss and bootstrap only. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
selectedstate catches up, so taps no longer flash back to the previous tab.none; leave re-fits resting chrome toion-tab-bar/ FAB.Test plan
ShellSnapshotTestspending + lock + searchable optimistic cases (already green locally)Made with Cursor