Skip to content

feat: support app-local Native UI Shell extensions - #276

Merged
rdlabo merged 3 commits into
mainfrom
feat/native-ui-extensions
Oct 17, 2026
Merged

rdlabo merged 3 commits into
mainfrom
feat/native-ui-extensions

Conversation

@rdlabo

@rdlabo rdlabo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Applications can now define Native UI Shell projections inside their own project, using a local defineNativeUI() DOM reader and a named, typed SwiftUI renderer registered with NativeUIShell.register(). No separate npm package, library, or Capacitor plugin is needed.

The Web element remains the source of truth. The shell synchronizes JSON props, dispatches native actions to the original Web targets, and owns projection acknowledgement, suspension, fallback, and teardown. NativeUIActions.activate() is async and completes after Web click dispatch; rejection, cancellation, retirement, and bridge timeouts settle pending actions.

  • Default element placement follows the source frame. Sources clipped by overflow ancestors, including Ionic shadow scroll containers, remain on the Web until fully visible.
  • Optional tab-accessory placement uses the system SwiftUI tab host. It currently requires Vertical Bars and iOS 26.1+; unsupported or ambiguous hosts keep the Web fallback.
  • Include app-local demo implementations and setup/lifecycle documentation.

Review

Supervisor and independent acceptance review cycles passed after fixing the independent review's clipping-boundary finding. Tests cover API contracts, ownership, fallback, disabled-action revalidation, async settlement, controller containment, and scroll recovery.

Validation

  • Package build, TypeScript checks, root formatting and demo lint passed.
  • Packed ESM imports passed.
  • Angular/Vitest: 52 passed.
  • Swift: 38 passed.
  • Browser extension and lifecycle regressions: 27 passed.
  • iOS simulator acceptance: element and Vertical Bars accessory actions, state synchronization, and modal retirement/restoration passed.
  • Production demo build passed.

Devin Review

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

Devin Review

Comment thread src/native/extensions.ts
Comment on lines +79 to +82
matches.length !== 1 ||
!visible(element) ||
element.closest('ion-modal, ion-popover, ion-menu') ||
element.contains(element.ownerDocument.activeElement)

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

タブアクセサリは元の DOM 要素の座標ではなく、所属するネイティブタブホストに描画されます。現在の visible(element) は画面内に完全に収まることを要求します(visible)。スクロールでソースが画面外になると候補が消え、read が次のスナップショットからアクセサリを除外します。

Example: ion-tabs 内のスクロール可能なページ上部にソースを置きます。下へスクロールしてソースを画面外にすると、固定タブバーは残るのにアクセサリが消えます。

Recommended fix: tab-accessory ではソースの画面内判定を適用せず、接続状態・非表示状態・除外設定とタブホストの有効性を個別に確認してください。通常の element 配置には現在の厳密なクリッピング判定を維持してください。

Devin Review


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

Comment thread src/native/runtime.ts
candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back,
);
return overlayVisible ? selected.filter((candidate) => isVerticalBarsCandidate(candidate.element)) : selected;
};

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.

🟡 トースト表示でタブアクセサリだけ消える

トースト表示中は既存のフィルタが縦バー候補だけを残します。アクセサリは縦バー候補ではないため、タブバーが残っても退避します。

(Refers to this code)

Learn more

overlayOpen はトーストやピッカーを縦バー保持オーバーレイとして扱い、overlayVisible が true の状態でもネイティブタブバーの候補を残します。アクセサリは通常の HTML 要素なので isVerticalBarsCandidate に該当せず、この末尾のフィルタにより除外されます。除外されたソースは次の同期で Web に戻り、ネイティブアクセサリは解除されます。

Example: 縦バーのタブに固定アクセサリを表示してトーストを開きます。タブバーは残りますがアクセサリだけ消え、閉じると再表示されます。

Recommended fix: 縦バー保持オーバーレイ時には、保持されるネイティブタブバーに所属する tab-accessory 候補も最終フィルタで残してください。モーダルなど、縦バーも退避させるオーバーレイの挙動は変えないでください。

Devin Review


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

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Playwright test results

passed  448 passed
flaky  1 flaky

Details

stats  449 tests across 23 suites
duration  4 minutes, 45 seconds
commit  78bade8
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.

Flaky tests

chromium › native-ui-shell.spec.ts › Vertical Bars popover retains native controls and background geometry (full shell)

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

github-actions Bot commented Oct 16, 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-276/

Ionic 8 runs against the same screenshots in a separate matrix job. View both results in the workflow run.

@rdlabo

rdlabo commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

/update-screenshots

@github-actions

Copy link
Copy Markdown
Contributor

✅ Screenshots have been updated successfully!

The new screenshots have been committed to this PR.

@rdlabo
rdlabo merged commit 90e9aec into main Oct 17, 2026
6 of 8 checks passed
@phiamo

phiamo commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Thank you for taking the extension path in #276 / #277, and for the Now Playing sample. Sorry we were slow to come back after your comment on #248 — by then you had already landed this, which was the right call.

We moved the mini-player into the app and it works really well. Default placement: 'element' is the right host for ordinary UIKit tabs. On iOS 26 the native capsule, tab bar, and Home segment all project; play/pause and open Now Playing go through actions.activate(). Web stays the source. Extra fields (subtitle, progress, http artwork, slide-to-exit) stay in the app — we did not copy the demo renderer.

We pin main (90e9aec) until there is an npm release past 1.2.1.

export const miniPlayer = defineNativeUI({
  name: 'app.mini-player',
  selector: '[data-mini-player]',
  read(element) {
    if (element.closest('ion-modal, ion-popover, ion-menu')) return null;
    return {
      props: {
        title: element.getAttribute('data-title') ?? '',
        subtitle: element.getAttribute('data-subtitle') ?? '',
        playing: element.getAttribute('data-playing') === 'true',
        progress: finiteProgress(element.getAttribute('data-progress')),
        artwork: artworkOf(element),
      },
      actions: {
        open: element.querySelector('[data-mini-player-open]'),
        toggle: element.querySelector('[data-mini-player-toggle]'),
      },
    };
  },
});

await enableNativeUIShell({
  controls: { tabs: true, segment: true, toolbar: true, fab: false },
  extensions: [miniPlayer],
});
try NativeUIShell.register("app.mini-player") { (props: MiniPlayerProps, actions: NativeUIActions) in
  MiniPlayerView(props: props, actions: actions)
}

A few things we handled in the app, in case they help other hosts — no change needed from you:

  1. visible() skips action targets under a non-identity ancestor transform (the source itself and ion-tab-bar are already exempt). Our exit rail used translateX, so native play/open reported Action unavailable. Switching the rail to margin-left fixed it.
  2. ionModalWillPresent unprojects extensions for a frame. We hide the inner Web chrome before present(), keep action nodes in the DOM, and avoid .disabled(busy) on the whole capsule (that dimmed on every tap).
  3. Footer ion-button with fill default in ion-footer > ion-toolbar becomes native glass. fill="solid" (or outline / a color) keeps it on Web.

fitsClip was fine for us: overflow is on the source itself.

If you ever want library follow-ups, the two that would have saved us work are: treat transform inside the extension source as visible for its actions, and keep extensions up until ionModalDidPresent. We are happy either way.

I am very grateful for the plugins, and for turning this into something apps can own. Thank you very much!!

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.

2 participants