Skip to content

fix(native): optimistic tabs and searchable projection lock - #184

Merged
rdlabo merged 8 commits into
mainfrom
fix/native-tab-selection-and-search-keyboard-frame
Sep 17, 2026
Merged

rdlabo merged 8 commits into
mainfrom
fix/native-tab-selection-and-search-keyboard-frame

Conversation

@rdlabo

@rdlabo rdlabo commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep ordinary (and searchable) tab selection optimistic until the Web selected state catches up, so taps no longer flash back to the previous tab.
  • While searchable is active, freeze Web layout projection and hold Capacitor Keyboard resize at none; leave re-fits resting chrome to ion-tab-bar / FAB.

Test plan

  • Simulator: tap tabs rapidly — selection indicator should move directly without bouncing to the prior tab
  • Simulator (Library searchable): Enter search → Leave → switch tab — resting tab widths stay stable
  • Simulator: open searchable, focus field / keyboard — search chrome stays owned by UIKit without double offset
  • ShellSnapshotTests pending + lock + searchable optimistic cases (already green locally)

Made with Cursor


Devin Review

rdlabo and others added 2 commits September 17, 2026 12:54
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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Devin Review

Comment on lines +307 to +310
guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
if let controlId = controls.first(where: { $0.value === tabBar })?.key {
pendingTabSelections[controlId] = .start(itemId)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 拒否されたタブが選択表示のまま残る

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 の双方に同じ期限処理を適用してください。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +45 to +54
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' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 キーボードのリサイズ設定が none に固定される

モード取得中に検索を閉じると、解放後に古い非同期処理が none を設定します。以後の Web キーボード表示で期待したリサイズが行われません。

Learn more

検索開始時の処理は、先に heldKeyboardResize を設定してから getResizeMode() を待ちます。待機中に検索を閉じると、releaseKeyboardResize() は復元して状態を null にします。しかし古い開始処理はその後も続き、取得結果を再保存して none を設定します。以後は検索が非アクティブなので、対応する解放処理が再度呼ばれる保証はありません。

Example: getResizeMode() に 200 ms かかる端末で検索を開き、すぐ閉じます。解放処理が native を復元した後、200 ms 時点で古い処理が none に変更します。通常の Web 入力を開いても WebView は設定済みの方式でリサイズされません。

Recommended fix: 保持処理に世代トークンまたはキャンセル可能な Promise 状態を追加してください。各 await 後に同じ保持世代がまだ有効で、アクティブな投影が存在することを確認してから heldKeyboardResizenone を適用してください。途中で解放された場合は取得した元モードを変更せず、遅れて none が適用済みなら直ちに元へ戻してください。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +265 to +269
if active {
if lockedWebFrame == nil {
lockedWebFrame = surface.bounds.isEmpty ? webFrame : surface.frame
}
if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 画面回転後も検索面が旧サイズに固定される

検索中に回転すると、新しい画面フレームを無視して回転前のフレームを再設定します。検索 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 レイアウトも再計算してください。キーボード表示中の高さ変化だけを無視する方式が安全です。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +204 to +221
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 無効化された閉じるボタンで検索を終了できない

検索中のキャッシュ経路は、閉じるボタンの適格性検査を省略します。ボタンが動的に無効化されるとネイティブ 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 と区別して扱ってください。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +33 to +38
const keyboardPlugin = (): KeyboardPlugin | undefined =>
(
doc.defaultView as Window & {
Capacitor?: { Plugins?: { Keyboard?: KeyboardPlugin } };
}
)?.Capacitor?.Plugins?.Keyboard;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Keyboard プラグインの取得方法を確認する

リサイズ制御は window.Capacitor.Plugins.Keyboard に依存します。モジュール利用時もグローバル登録されるか確認してください。取得不能なら二重リサイズ対策が動きません。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/native/runtime.ts Outdated
Comment on lines 151 to 153
if (doc.hidden || overlayOpen()) return [];
if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive())
return [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 検索中の可視ビューポート例外が全コントロールへ及ぶ

一つの検索がアクティブな間、ズームや viewport offset による退役を全投影で省略します。検索面だけの例外でよいか確認してください。

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Playwright test results

failed  18 failed
passed  287 passed
flaky  27 flaky

Details

stats  332 tests across 18 suites
duration  6 minutes, 40 seconds
commit  d5203b2
info  This detailed result covers Ionic 9 only. Ionic 8 runs against the same screenshots in a separate matrix job; check the workflow run for both results. To update the screenshots, comment with /update-screenshots.

Failed tests

chromium › animation.spec.ts › Animation Tests › runs and completes the iOS page transition
chromium › native-ui-shell.spec.ts › a cached search registration does not block a second page sharing the tab bar
chromium › popover.spec.ts › ordinary anchors show an arrow and morphing buttons do not
chromium › popover.spec.ts › popover can be presented without a trigger
chromium › popover.spec.ts › popover stays in its content pane at viewport width 390
chromium › popover.spec.ts › popover stays in its content pane at viewport width 1210
chromium › popover.spec.ts › real demo triggers keep the surface and arrow aligned at 390px
chromium › popover.spec.ts › real demo triggers keep the surface and arrow aligned at 1210px
chromium › popover.spec.ts › event reference points to the click on button for top placement
chromium › popover.spec.ts › event reference points to the click on button for bottom placement
chromium › popover.spec.ts › event reference points to the click on button for left placement
chromium › popover.spec.ts › event reference points to the click on button for right placement
chromium › popover.spec.ts › event reference points to the click on ion-button for top placement
chromium › popover.spec.ts › event reference points to the click on ion-button for bottom placement
chromium › popover.spec.ts › event reference points to the click on ion-button for left placement
chromium › popover.spec.ts › event reference points to the click on ion-button for right placement
chromium › submit-color.spec.ts › submit preserves primary color and contrast when pressed
chromium › tab-bar-motion.spec.ts › drag speed changes lens proportions and stopping rebounds to the held size

Flaky tests

chromium › native-ui-shell-edge.spec.ts › Push and repeated back match Web with collapsing Web headers
chromium › native-ui-shell.spec.ts › all demo pages keep projection consistent through consecutive navigation
chromium › range-interaction.spec.ts › dual thumbs remain within their resting width at 0 in ltr
chromium › range-interaction.spec.ts › dual thumbs remain within their resting width at 100 in ltr
chromium › range-interaction.spec.ts › activeBarStart never shows a reflection on the wrong side in ltr
chromium › range-interaction.spec.ts › dual thumbs remain within their resting width at 0 in rtl
chromium › range-interaction.spec.ts › dual thumbs remain within their resting width at 100 in rtl
chromium › range-interaction.spec.ts › activeBarStart never shows a reflection on the wrong side in rtl
chromium › submit-color.spec.ts › submit preserves default color and contrast when pressed
chromium › submit-color.spec.ts › submit preserves danger color and contrast when pressed
chromium › submit-color.spec.ts › submit preserves brand color and contrast when pressed
chromium › tab-bar-position.spec.ts › ltr top 2 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr top 3 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr top 4 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr top 5 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr bottom 2 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr bottom 3 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr bottom 4 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › ltr bottom 5 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl top 2 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl top 3 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl top 4 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl top 5 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl bottom 2 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl bottom 3 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl bottom 4 tabs support all positions without shifting the press origin
chromium › tab-bar-position.spec.ts › rtl bottom 5 tabs support all positions without shifting the press origin

github-actions Bot added a commit that referenced this pull request Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

📊 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.

github-actions Bot added a commit that referenced this pull request Sep 17, 2026
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>
github-actions Bot added a commit that referenced this pull request Sep 17, 2026
rdlabo and others added 3 commits September 17, 2026 14:54
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>
github-actions Bot added a commit that referenced this pull request Sep 17, 2026
github-actions Bot added a commit that referenced this pull request Sep 17, 2026
rdlabo and others added 2 commits September 17, 2026 15:07
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>
@rdlabo
rdlabo merged commit d1946e7 into main Sep 17, 2026
7 of 9 checks passed
github-actions Bot added a commit that referenced this pull request Sep 17, 2026
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