Skip to content

feat: add cross-platform NativeWindow implementation - #11181

Merged
NathanWalker merged 23 commits into
mainfrom
feat/unified-multi-window
Aug 21, 2026
Merged

feat: add cross-platform NativeWindow implementation#11181
NathanWalker merged 23 commits into
mainfrom
feat/unified-multi-window

Conversation

@edusperoni

@edusperoni edusperoni commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

PR Checklist


The concept

  1. Application — the full context. This is where JS lives. This is the browser "window".
  2. NativeWindow — a browser tab / OS window. Multiple windows live in the same JS context.

The developer chooses how to handle this. From an Angular background:

  1. Every window is a fully new app bootstrapApplication(...) — "root" services are per-window, "platform" services are shared.
  2. A single application is bootstrapped and every window is a different createComponent — "root" services are shared, navigation is similar to modal navigation.

Motivations: iOS 27 makes UIScene mandatory; Android's singleTask requirement exists only because the framework binds everything to one root activity; CarPlay/Android Auto need a home; Windows runtimes are coming.

What is the current behavior?

NativeWindow existed only as a draft POC. Window content was still owned by the platform application classes, windows died whenever their native surface went away, the window registry was duplicated per platform, and launch conflated "the process started" with "build the first window's UI" — which cannot work for a second window or a background launch.

What is the new behavior?

The POC is now a complete implementation, landed as 14 individually-typechecking commits.

  • WindowBase / NativeWindow split. WindowBase carries identity, role, state, lifecycle and native accessors; NativeWindow adds the view-carrying half. Surfaces with no NativeScript view tree (CarPlay, external displays) can extend WindowBase later. Roles: application | embedded | carplay | externalDisplay.
  • Session-scoped identity. A window survives its native surface going away. iOS keys off UISceneSession.persistentIdentifier (and handles didDiscardSceneSessions), Android mints a UUID persisted in savedInstanceState. A scene disconnect or activity recreation raises detached — the window stays registered and keeps its listeners — while a real close raises close exactly once and then clears the instance's listeners so apps don't leak handlers.
  • Ownership inversion. All content flows through window.setContent(). Every app registers at least one window: scene, non-scene, and embedded alike. App-level root state (getRootView(), the global root view, initRootView) now mirrors the primary window instead of owning it.
  • ready + content resolver. ready fires exactly once per JS context, is never deferred (including background launches), and always precedes the first windowOpen. Application.setWindowContentResolver() supplies each window's UI on demand. launch keeps working as a bridge for the first view-carrying window, including its root three-state contract.
  • Per-window configuration. Orientation, system appearance and layout direction are read live per window, raise per-window changed events, and scope their CSS classes to that window's root view and its modals rather than the process-wide system class list.
  • Shared registry. primaryWindow, getWindows(role?), getWindowById(), openWindow(options?) and the window events live on ApplicationCommon, with protected hooks for iOS-specific bookkeeping.
  • Android openWindow is implemented for real (new activity via intent) and flagged experimental.
  • Public typings for native-window following the ui/frame hand-written index.d.ts convention, keeping internals and the concrete platform classes out of the published surface.

Test suite: 212 → 287 tests across 19 files.

Bugs fixed along the way

  • Android exit could be lost entirely. The last-window check initially went where the old code lived, in a callback that only runs for NativeScript activities — but windows are registered for every activity, so a third-party SDK activity outliving the main one meant exit never fired.
  • app.css would never load for resolver-only apps. loadAppCss() is reachable from exactly one place, inside notifyLaunch(), which the resolver chain skips.
  • window.setContent(view) discarded its argument on Android and rebuilt from the main entry.
  • _detach() disposed native views without callUnloaded, so unloaded handlers never ran.
  • classNameProperty dropped window-scoped CSS classes — it clears cssClasses and reseeds from the global list, so a className change on a root or modal-root view lost ns-dark/ns-landscape.
  • Rotating one window restyled another window's modals (_getRootModalViews() is process-wide).
  • Application.ios.on('ready' | 'suspend' | …) did not typecheck — both platform classes declared on() as method overloads, replacing ApplicationCommon's handler surface. Seven types in application.d.ts were referenced but never imported and silently resolved to any.
  • A second launch dispatch would tear down every launch listener (events.off('launch', null)).
  • autoSystemAppearanceChanged was bypassed by the per-window CSS path; accessibility CSS init was one-shot so windows 2+ never got a11y/font-scale classes; close fired twice per teardown; _getWindows() leaked the live internal array; the toolbox Info.plist was malformed XML (failed plutil -lint).

Known limitations

  • A detached Android window that never re-attaches keeps the registry non-empty and suppresses exit indefinitely. Suppression is correct for the normal recreation case and there is no clean discriminator.
  • NativeWindow repeats WindowBase's whole on() overload set — TypeScript requires a derived overload set to cover every base overload, and a catch-all cannot stand in for the SceneEventData/AndroidActivity* shapes.
  • displayed fires on the primary window only; secondaries deferred.
  • NotificationObserver is unexported, so it still resolves to any in the published typings.

Still to verify on device

iOS: scene reconnect/restore; didDiscardSceneSessions delivered on a later launch; close() denial clearing _closeRequested; suspend/resume ordering across iOS 15–27 with two scenes; background launch (silent push) content deferral; visionOS; embedded hosts; Vite HMR dev boot.

Android: openWindow against singleTask/singleInstance manifests and OEM recents; putExtra overload selection for wrapped primitives (resolved by the runtime, not by tsc); per-activity ComponentCallbacks2 register/unregister across rotation; window-id round trip after process death.

Both: CSS system-class consumers — modals, dark mode, rotation — since window-scoped classes no longer live in the global list.


BREAKING CHANGES

Change Migration
NativeWindow.iosWindow.ios, and its .window.uiWindow win.ios?.uiWindow, win.ios?.scene
NativeWindow.androidWindow.android win.android?.activity
SceneEventData.window is now the NativeWindow; the native UIWindow moved to uiWindow A window payload key always means a NativeWindow
getWindows() is role-filtered, defaulting to application + embedded getWindows('all') for every registered surface
Android exit fires only when the last window finishes Previously fired for any finishing activity. iOS exit is unchanged (process termination)
App suspend/resume in scene mode reflect whole-app state Use a window's background/foreground for a single window
Scene disconnect / activity recreation raise detached instead of destroying the window The window stays registered and its listeners survive
A window clears its listeners after close Do not re-use a closed window instance
CSSUtils.getRootViewCssClasses() no longer contains orientation/appearance/direction classes These are per-window; read them from the window

Deprecated but still working: launch (use ready + setWindowContentResolver()), shouldDelayLaunchEvent (now a no-op), Application.orientation()/systemAppearance()/layoutDirection() (delegate to primaryWindow), and the enumeration getters (getAllWindows/getAllScenes/getWindowScenes/getPrimaryWindow/getPrimaryScene).

Un-deprecated: the activity*/scene* bridges on Application. They are permanent aggregate APIs — they fire for every window, and args.window identifies which one.

@nx-cloud

nx-cloud Bot commented Apr 13, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 49b7f19

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 34s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-21 06:21:12 UTC

@NathanWalker

Copy link
Copy Markdown
Contributor

Great start here, it might be helpful to setup a new demo page in toolbox or repurpose multiple-scenes.ts to exercise the approach on both platforms?


// --- Typed event overloads ---

on(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

those should go into a native-window.d.ts file (which is missing by the way)

onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null;

/**
* @deprecated Listen on a NativeWindow instance instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i dont think we should mark those as deprecated. There is a lot of power in having Application events.
It is the only place where we can have all windows event from one single listener.
same for orientation and all other events.
I think it is great to have them both in NativeWindow and Application.
plus we ll never be able to remove them. Application events are deeply used in all existing apps and plugins.

getRegisteredBroadcastReceivers(intentFilter: string): android.content.BroadcastReceiver[];

/**
* @deprecated Listen on a NativeWindow instance instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same thing i would not deprecate those, only explain you can do it through NativeWindw

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should the android NativeWindow listen to configuration change, just like we do in application?
It should store it s configuration, font scale, orientation... use them for accessors but also notify the NativeWindow about them. Just like we do in Application.
maybe we can even have Application plugged to use accessors, events from the main window?
Not sure about this because in application we use context and not activity i think, not sure what difference it makes for configurations like orientation, font scale ...

Comment thread packages/core/ui/frame/index.android.ts Outdated
} as AndroidActivityNewIntentEventData);
}

// @deprecated - Bridge to Application.android for backward compat

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i wouldnt deprecate here again. It is really nice to have that

@NathanWalker NathanWalker added this to the 9.1 milestone May 16, 2026
- Introduced NativeWindow class to manage platform-specific window behavior for iOS and Android.
- Added interfaces and common functionality for NativeWindow, including event handling and lifecycle management.
- Implemented Android-specific NativeWindow logic to wrap AppCompatActivity and manage its lifecycle.
- Implemented iOS-specific NativeWindow logic to wrap UIWindowScene and UIWindow, handling view controller setup and trait collection changes.
- Updated core index files to export new NativeWindow functionality.
…ling

- Refactored NativeWindow to separate platform-specific implementations for Android and iOS.
- Introduced AndroidNativeWindow and IOSNativeWindow classes extending a common NativeWindow base class.
- Updated lifecycle event notifications to emit events directly from NativeWindow instances instead of the Application class.
- Deprecated direct event listeners on the Application class in favor of listening on NativeWindow instances.
- Removed redundant index files for native-window on both Android and iOS platforms.
- Enhanced type definitions for NativeWindow events and interfaces to improve clarity and maintainability.
@edusperoni
edusperoni force-pushed the feat/unified-multi-window branch from 44fbfb6 to cdd182b Compare August 20, 2026 16:48
edusperoni and others added 20 commits August 20, 2026 16:15
- restore missing </dict> in toolbox Info.plist scene manifest
- stop _destroy() emitting a second 'close' event (callers already notify)
- promote primary windows via _setIsPrimary() instead of an any-cast
- _getWindows() returns a copy instead of the live internal array
- read activity callbacks via the CALLBACKS constant
- make WindowCloseEventData.window required
- fix sceneContentSetup deprecation text referencing a nonexistent event
…heir payloads

The activity*/scene* events on Application are aggregate APIs that fire for
every window; args.window identifies which one. SceneEventData's native
UIWindow moves to uiWindow so that a 'window' payload key always means a
NativeWindow.
…ming collisions

WindowBase carries identity, role, state, lifecycle events and the native
accessors; NativeWindow adds the view-carrying half. Surfaces without a
NativeScript view tree (CarPlay, external displays) can extend WindowBase.

BREAKING CHANGE: NativeWindow.iosWindow is now .ios and its window property
is .uiWindow; NativeWindow.androidWindow is now .android. getWindows() is
role-filtered and defaults to application and embedded windows.
…exit rewire

A window now survives its native surface going away: iOS keys identity off
UISceneSession.persistentIdentifier, Android mints a UUID persisted in
savedInstanceState. A scene disconnect or an activity recreation detaches the
window - it stays registered and keeps its listeners - while a real close ends
the session, fires 'close' exactly once and then clears the instance listeners.

Android 'exit' now fires when the last window goes away rather than for any
finishing activity; iOS 'exit' remains process termination.
…nership inversion)

Every iOS app now registers at least one window - scene, non-scene and
embedded alike - and all content flows through NativeWindow.setContent().
The app-level root view state (getRootView(), the global root view and the
initRootView event) mirrors whatever the primary window shows.

Embedded windows hand their controller to the embedder delegate and never
touch rootViewController or makeKeyAndVisible: the host app owns the window.
resetActivityContent() accepts an explicit view, so NativeWindow.setContent()
no longer has its argument discarded and rebuilt from the main entry.

Root views built by the activity pipeline are handed to their window through
_adoptRootView(), which records the view and fires contentLoaded without
redoing the setup the pipeline already performed. Embedded hosts get a window
with role 'embedded'.
…cation

'launch' conflated process initialization with building the first window's UI,
which cannot work for additional windows or for background launches. 'ready'
now covers initialization - fired exactly once per JS context, never deferred,
always before the first windowOpen - while setWindowContentResolver() supplies
each window's UI on demand.

'launch' keeps working as a bridge for the first view-carrying window,
including its 'root' three-state contract. shouldDelayLaunchEvent is a
deprecated no-op.
…tions in scene mode

App-level suspend/resume were driven by the primary scene, so a multi-scene app
reported itself suspended while other scenes were still on screen. They now
follow UIApplication's own foreground/background notifications, which describe
the whole app. Per-window state stays available through NativeWindow events.
…m openWindow (Android experimental)

The registry, its events and primary promotion had one copy per platform; they
now live on ApplicationCommon, with protected hooks for the iOS-specific
bookkeeping. openWindow() takes WindowOpenOptions on both platforms.

Android openWindow() launches a real second activity. It is experimental:
whether a new window appears depends on the manifest launchMode and on OEM
recents behavior.
Orientation, system appearance and layout direction are per-window: each
NativeWindow reads them live from its own surface, raises its own changed
events, and scopes its CSS classes to its own root view and that root's modals
instead of the process-wide system class list. Application-level getters and
events continue to reflect the primary window.

Accessibility CSS is applied per root view, so windows beyond the first are no
longer skipped.
…ttern

Adds a curated public surface for native-window - WindowBase, NativeWindow,
the event constants and payload types, the resolver types - keeping internals
and the concrete platform classes out of the published typings.

Also brings application.d.ts back in line with the implementation: the platform
classes declared on() as method overloads, which hid ApplicationCommon's own
handler surface, and several referenced types were never imported and silently
resolved to any.
Covers the window lifecycle contract (attach/detach/re-attach/close, close
firing once, listeners cleared only after close and surviving a detach), the
registry's role filtering and primary promotion, the content resolution chain
including the launch bridge's three-state root and app CSS loading on every
path, and the Android trait converters and session-id round trip.
Rewrites the multiple-scenes page on the shared window API: windows are opened
with Application.openWindow({ data }), their UI comes from a
setWindowContentResolver, and each row shows live id/role/state/traits driven
by that window's own events. Gating is by capability rather than platform, so
the page renders and explains itself on Android too.
…none remain

unregisterBroadcastReceiver empties the entry rather than deleting it, so the
optional chain guarded the array but not its first element and the getter threw
for a filter that had been registered and then unregistered.
…hods

Once an app adopts scenes, UIKit stops calling applicationOpenURLOptions,
applicationContinueUserActivityRestorationHandler and
applicationPerformActionForShortcutItemCompletionHandler and delivers them to
the scene delegate instead, so handlers registered through addDelegateHandler()
stopped firing - deep links, universal links, Handoff and quick actions.

The scene delegate now implements all three, raising a per-window event and its
Application-level bridge, then forwarding to the legacy delegate method so
existing handlers keep working. A quick action's completion handler is shared
between listeners and the legacy handler and reports at most once.
…window adopts it

Views had no supported route back to the window showing them. getNativeWindow()
walks up to the root view - through the presenting view of any modal - and
reads the window that owns it, resolving on every call so a view re-parented
into another tree reports where it moved to.

iOS lets a UIView change windows, and setContent() only ever reset its own
previous root, so two windows could claim the same root view and UIKit could be
left holding a rootViewController it no longer hosts. A window now releases its
root when another adopts it, without tearing the view down.
Reading the activity callbacks key pulled native-window.android into
ui/frame/frame-helper-for-android, which reaches back through
fragment.transitions.android to application.android. The key and the frame id
now live in a module of their own with no imports, so both sides can read them
without the cycle.

The frame-common import alongside it was types only and is now an import type;
NavigationEntry there was unused.
Both back paths resolved the pressing activity's own root view and then handed
navigation to Frame.goBack(), which walks the process-global frame stack - so
with two windows a back press in one window could navigate the other, whichever
had navigated most recently.

Frame.goBack() now takes an optional frame to go back from, defaulting to the
topmost as before, and the Android back paths pass the frame they already
resolved. The terminal pop is skipped for a frame that is not at the top of the
stack, which would otherwise throw.
The frame stack is process-global and a frame reaches its top on navigate, so
with more than one window Frame.topmost() returned whichever frame navigated
most recently anywhere - sending dialogs, keyboard dismissal and back
delegation to the wrong window.

Frame.topmost() now takes an optional window and defaults to the newly exposed
Application.activeWindow. A frame that belongs to no window still wins outright,
so single-window behaviour is unchanged in every arrangement.
@CatchABus

Copy link
Copy Markdown
Contributor

Other than @farfromrefug points that need to be considered, it looks good to me and will definitely bring lots of new possibilities in core.

@NathanWalker
NathanWalker marked this pull request as ready for review August 21, 2026 15:44
@NathanWalker
NathanWalker merged commit e5ea71e into main Aug 21, 2026
8 checks passed
@NathanWalker
NathanWalker deleted the feat/unified-multi-window branch August 21, 2026 20:02
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.

4 participants