Skip to content

feat: add opt-in native overlay content relay - #260

Open
rdlabo wants to merge 18 commits into
mainfrom
feat/native-overlay-relay
Open

rdlabo wants to merge 18 commits into
mainfrom
feat/native-overlay-relay

Conversation

@rdlabo

@rdlabo rdlabo commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add opt-in overlay content relay above Native UI Shell. controls.modal, controls.popover, and controls.alert each default to false; applications enable only the components they need.

Ionic keeps its overlay host, controller registry, lifecycle, application state, and dismissal handlers. Existing live content moves into a native-hosted WebView through a shared relay layer for styles, focus, restoration, and cleanup. Covered page controls remain natively projected while the relay is active.

  • Modal: normal presentations use a native full-screen controller; Card and Sheet use UIKit sheet presentation and transitions. Sheet breakpoints, native dragging, and dismissal permission synchronize with Ionic. Eligible controls in full-width Vertical Bars modals use the same projection rules as page controls.
  • Popover: ordinary Native UI Shell buttons retain their current geometry and anchor a standard UIKit arrow popover using sourceView and sourceRect. Vertical Bars uses SwiftUI's standard toolbar popover. Ionic supplies initial content size/background; the native presentation owns its arrow, corners, outline, and transition. Popover chrome follows the Web theme's dark/light appearance.
  • Alert: Ionic's rendered inputs, buttons, and live handlers appear in a native-hosted WebView above the shell; this does not translate them into UIAlertController.

The relay waits for the child WebView's blank navigation before adopting content, preserving handlers across initialization and repeated presentation. Component-specific helpers share the controller and dismissal primitives. Demo examples cover inline/compact/scrollable Popovers and an Alert input callback.

Preview limitations

  • One overlay is relayed at a time. Nested overlays restore the current relay to the source WebView. Suspension, teardown, and shell opt-outs also restore Web rendering.
  • --height: auto modals stay on the Web. Native presentation follows Ionic's didPresent; custom Web animations do not replace native transitions.
  • The original Modal host is not moved. Source-host DOM queries cannot find adopted content. Modal host-dependent selectors and references to elements/forms left in the source document cannot cross documents.
  • Ordinary toolbars/tabs inside relayed content remain Web-rendered; eligible full-width Vertical Bars modal controls are the supported exception.
  • Full VoiceOver/hardware-keyboard parity and arbitrary custom CSS compatibility are not certified. This remains an opt-in preview, not a guarantee of complete Ionic overlay parity.

Validation

  • Formatting/lint, library build, demo production build, and Capacitor iOS simulator build passed.
  • Focused browser regression suite: six tests passed across retained Modal projection, foreground normal/Card/Sheet controls, and Popover/Alert relay. The two live-handler/window-cleanup guards passed again after the final changes.
  • iPhone 17 Pro and iPhone Duo: repeated Popover presentation/dismissal, native and Web trigger origins, unchanged normal UIButton geometry, and live increment handlers verified. Alert input/callback and repeated reopening verified on both devices.
  • Duo dark-mode screenshots verified native arrow/border and Vertical Bars material appearance.
  • Only two permanent relay regression tests were added. Investigation apps, geometry probes, and simulator confirmation tests remain outside the repository.

No package version change. Toolbar geometry investigation is separate from this PR.

@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 5 potential issues.

Devin Review

Comment thread src/native/overlays/controller.ts Outdated
Comment thread src/native/overlays/styles.ts Outdated
for (const child of Array.from(node.children)) collect(child);
};
content.forEach(collect);
for (const element of content) destination.append(destination.ownerDocument.adoptNode(element));

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.

🟡 モーダル内の外部フォームが送信できない

モーダル内の入力や送信ボタンが外側のフォームを form 属性で参照すると、別 Document への移動で関連が切れます。送信しても元のフォームのハンドラは動きません。

Learn more

HTML の form="id" は同じ Document 内のフォームとの関連を作ります。リレーはモーダルのコンテンツだけを子 WebView の Document に移し、元の Document にある外部フォームは移しません。したがって、form 属性付きボタンや入力はフォームの構成要素でなくなります。既存の フォーム送信の契約 と異なり、submit ハンドラも呼ばれません。

Example: 元の画面の <form id="edit" onsubmit="..."> をモーダル内の <ion-button type="submit" form="edit">保存</ion-button> から送信する場合、リレー後はボタンとフォームが異なる Document にあるため送信されません。

Recommended fix: 同一 Document の外部フォームに依存するコンテンツはリレー対象から除外するか、フォームとの関連および送信を元の Document に明示的に中継してください。

Devin Review


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

const autoHeight =
overlay.localName === 'ion-modal' && doc.defaultView!.getComputedStyle(overlay).getPropertyValue('--height').trim() === 'auto';
if (overlay.localName !== 'ion-modal' || overlay.breakpoints?.length || autoHeight) {
opening = opening.then(restoreWeb).catch(console.error);

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.

🟡 別のオーバーレイが開いてもモーダル画面がすぐに退かない

モーダルのネイティブ表示を準備中に別のオーバーレイを開くと、復元は準備完了まで待機します。その間、後から開いたオーバーレイはネイティブ画面に覆われます。

Learn more

opening はネイティブブリッジの prepareOverlay と presentOverlay を待つ直列キューです。未対応オーバーレイの WillPresent で restoreWeb をその末尾に追加しても、進行中の connect は中断されません。進行中のオープンが完了するまではネイティブ画面が上に残り、後続の Ionic オーバーレイを隠します。

Example: モーダルの表示準備中に ionAlertWillPresent が発火すると、Alert が開き始めても presentOverlay の応答が来るまでモーダルのネイティブ画面は閉じません。

Recommended fix: 未対応オーバーレイの発生を同期的な世代番号やキャンセルトークンで記録し、connect の各ブリッジ応答後に確認して表示を取り消してください。必要なら既に開いた画面を直ちに閉じる処理も行ってください。

Devin Review


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

original = source.uiDelegate
automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically
super.init()
source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true

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.

🟨 モーダル有効化中は任意のスクリプトがポップアップを自動で開ける

モーダルリレーは共有 WebView のポップアップ制限を常時解除します。ページ内の無関係なスクリプトもユーザー操作なしでウィンドウを開けます。

Devin Review


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

Comment on lines +40 to +45
guard let id = prepared, navigationAction.targetFrame == nil,
navigationAction.request.url?.absoluteString == "about:blank" else {
return original?.webView?(webView, createWebViewWith: configuration,
for: navigationAction, windowFeatures: windowFeatures)
}
prepared = nil

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.

🟨 無関係な空ウィンドウがモーダル表示枠を奪える

準備後の最初の about:blank ウィンドウを、発行元を照合せずネイティブ画面に割り当てます。別のスクリプトが先に開くと、そのウィンドウがモーダル用の表示枠を占有します。

Devin Review


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

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Playwright test results

failed  13 failed
passed  381 passed

Details

stats  394 tests across 21 suites
duration  4 minutes, 41 seconds
commit  a7cbb3e
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 › native-ui-shell.spec.ts › native modal preparation retains the covered projection and releases it on fallback
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:all
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:button-only
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:remove-app
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for alert:preferred
chromium › screenshot.spec.ts › Screenshot Tests - All Routes › should match screenshot for popover
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:all
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:button-only
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:no-cancel
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:remove-app
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for alert:preferred
chromium › screenshot.spec.ts › Screenshot Tests - Dark Mode › should match dark mode screenshot for popover

github-actions Bot added a commit that referenced this pull request Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 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-260/

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 Oct 2, 2026
github-actions Bot added a commit that referenced this pull request Oct 2, 2026
github-actions Bot added a commit that referenced this pull request Oct 2, 2026
github-actions Bot added a commit that referenced this pull request Oct 2, 2026
github-actions Bot added a commit that referenced this pull request Oct 2, 2026
@rdlabo rdlabo changed the title feat: add opt-in native Modal content relay feat: add opt-in native overlay content relay Oct 3, 2026
github-actions Bot added a commit that referenced this pull request Oct 3, 2026
github-actions Bot added a commit that referenced this pull request Oct 3, 2026
rdlabo and others added 2 commits October 4, 2026 15:16
WebKit percent-encodes the '#' marker in the about:blank popup URL, so the
window interception never matched and every relay silently fell back to
Web rendering. Compare the decoded URL on both sides of the bridge.

- Release the relay when an Ionic menu opens so the menu is never hidden
  beneath the native overlay.
- Bound bridge, lifecycle, layout and child-load waits so a stalled
  presentation restores Web ownership instead of hanging the opening
  queue; a timed-out early wait now also releases the projection
  retention through the shared release path.
- Consume the modal handoff flag only when a sync actually runs, so a
  retained sync cannot eat the instant transition needed by the deferred
  retirement after the overlay releases.
- Mark the overlay host view as modal for accessibility so covered page
  projections stay out of VoiceOver while they remain projected.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- While a relayed overlay is up, covered page controls stay projected but obscured; the XCTest now asserts that state and taps the relayed button through a screen coordinate (the hosted WebView's content is not hit-testable by XCTest). The Web fallback path keeps its original assertions.
- A disabled ion-buttons group keeps its direct children projected individually, so the stale nonexistence assertions are corrected.
- Add an e2e guardrail: opening an Ionic menu restores a relayed modal to the source WebView.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
The relay previously suppressed Ionic's animation and then replayed a
second UIKit presentation, leaving a dead gap of a second or more between
the tap and any visible response. Keep the Web enter animation visible
while the child window stages underneath it, freeze the rendered overlay
at didPresent, and present the native host instantly over identical
pixels. The placeholder lifts once the hosted document paints, so the
swap is imperceptible and perceived startup matches ordinary Ionic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
On iOS 26/27 the anchor itself becomes the popover surface, so a separate
bubble beside the pill read as a z-order bug: the glass capsule bled the
popover edge through and SwiftUI's .popover(item:) drew rail-anchored
popovers below the toolbar layer. Grow a window-level surface out of the
projected control instead, collapse it back on dismissal, and drop the
SwiftUI popover path that could no longer be reached. When the projected
view is gone the page's measured anchor rect still drives a plain UIKit
popover.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
The relay kept every projection alive for the overlay's lifetime, so a
modal's enter animation ran under the still-visible native pills. Only
popovers need that retention — the page stays visible and the morph needs
its anchor — while modals and alerts should hide covered controls through
the same willPresent retirement the Web path always used.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
A card modal was presented with a .large() detent, which fills the iPhone
screen and made it indistinguishable from a normal modal. The card now
uses a custom detent below the maximum height, sized from a topInset the
page measures with the Ionic card formula (max(30, safe-area-top) + 10),
so the shrunken presenting page peeks above the sheet again.

Every relayed popover also played two animations at once: the Ionic Web
enter ran first and the native surface swapped in afterwards, which
flickered and re-laid out mid-flight. The Web enter is now hidden for
all relayed popovers - anchored ones morph out of the projected control
and the rest present with UIKit's own popover animation - and no source
snapshot is taken because nothing is ever revealed.

The anchored morph itself reads more like the button unfolding now: the
content is revealed by the clip instead of fading in separately, and on
dismissal the surface stays opaque while it shrinks back into the
capsule rather than vanishing mid-collapse.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
UIKit keeps the popover chrome unresolved when the surface presents
before its transition view completes a first render pass — most visible
on the very first popover after launch (flat pill, no shadow). Walk to
the popover container during host layout and pin the same shadow the
anchored morph surface uses so every presentation renders identically.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
Ionic's disconnectedCallback does not dismiss a presented overlay, so a
popover removed or page-hidden without ionPopoverDidDismiss kept its
retain() lease forever. sync() then returned early on every later update
and the last control snapshot stayed frozen on screen — stale glass
pills stacking over subsequent pages and their modals.

Watch overlay ancestors for detach and exclusion (ion-page-hidden /
ion-page-invisible) and release the connection. Also run
releaseProjection before child-window teardown so a throwing close()
cannot skip it, and cover the dismiss-then-navigate path with a UI test.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
A dismissed UIKit popover could leave retentions stuck at 1 and freeze
every later control sync: moveContent restore threw NotAllowedError
when reassigning adoptedStyleSheets (WebKit re-associates the sheets
with the new document across adoptNode), which aborted cleanup before
the finally block that releases the projection lease and closes the
native host — so the next overlay prepare call also rejected.

Run every teardown step through an isolated, bounded stage so a
throwing or hanging step cannot skip the release, and fall back to an
inline style copy when a shadow root adopted sheets no longer match
its document.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 5, 2026
UIKit's default popover chrome draws a ~15pt corner while the theme
spec and the anchored morph surface are 34pt — the fallback read
visibly squarer than both the SwiftUI idiom and our own morph.
ShellPopoverBackgroundView (a public UIPopoverBackgroundView subclass
registered via popoverBackgroundViewClass) now draws the silhouette:
34pt rounded body + arrow wedge, a UIGlassEffect surface masked by the
shape path, and the same 0.18/24/(0,8) shadow the morph pins. The host
clips the relayed document to the same radius so its square corners
stay inside the shape. First-launch shadow stability now comes from
the background view's own shadowPath, replacing the private-hierarchy
pinning entirely.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 5, 2026
…utton

A grouped toolbar button's visible pill is the ion-buttons backdrop-filter
capsule, one padding ring wider than the projected UIButton. Growing the
morph surface from the inner button left the Web capsule's rim peeking
out of the surface's rounded corner.

Send the capsule rect as the morph's start frame and hide the capsule
while the surface is up, restoring it after native dismissal. Fall back
to the projected view's frame when no anchor rect is provided.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
github-actions Bot added a commit that referenced this pull request Oct 5, 2026

This branch has not been deployed

No deployments
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