From 08aaae517d6760a60bba68603788b88a72da232d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:22:29 +0000 Subject: [PATCH 1/4] feat(mobile, app-shell): native connection switcher and connectionRemoved in the mobile shell The header organization button opens the shell's native switcher when the bridge announces showConnectionSwitcher; useNativeHostRouting answers the shell's connectionRemoved event through event.waitUntil. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTVYu9vDSx28X2zi7h --- .../29458-native-connection-switcher.md | 16 +++ pnpm-lock.yaml | 3 + ui/packages/app-shell/package.json | 1 + .../src/layout/WorkspaceSwitcher.tsx | 49 ++++++- ...Switcher.nativeConnectionSwitcher.test.tsx | 126 ++++++++++++++++++ .../mobile/src/__tests__/nativeHost.test.tsx | 114 +++++++++++++++- ui/packages/mobile/src/index.ts | 4 + ui/packages/mobile/src/nativeHost.ts | 45 ++++++- ui/packages/mobile/src/useNativeHost.ts | 44 +++++- 9 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 .changeset/29458-native-connection-switcher.md create mode 100644 ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx diff --git a/.changeset/29458-native-connection-switcher.md b/.changeset/29458-native-connection-switcher.md new file mode 100644 index 00000000000..f1485100be1 --- /dev/null +++ b/.changeset/29458-native-connection-switcher.md @@ -0,0 +1,16 @@ +--- +'@object-ui/mobile': patch +'@object-ui/app-shell': patch +--- + +feat(mobile, app-shell): inside the ObjectStack mobile shell, the header's organization button opens the shell's native connection switcher, and the Console answers the shell's `connectionRemoved` event + +Clause-②: yes (widening) +Metadata-protocol: none + +The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both: + +- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In a plain browser, or in a shell that does not announce the capability, the button opens the web menu as before. +- **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists. + +`@object-ui/mobile` adds `showConnectionSwitcher` to `ObjectStackNative` and `NativeHostMethod`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c11f56608b8..05582801828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3915,6 +3915,9 @@ importers: '@object-ui/layout': specifier: workspace:* version: link:../layout + '@object-ui/mobile': + specifier: workspace:* + version: link:../mobile '@object-ui/permissions': specifier: workspace:* version: link:../permissions diff --git a/ui/packages/app-shell/package.json b/ui/packages/app-shell/package.json index 31e8966668c..1424162b689 100644 --- a/ui/packages/app-shell/package.json +++ b/ui/packages/app-shell/package.json @@ -83,6 +83,7 @@ "@object-ui/fields": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/layout": "workspace:*", + "@object-ui/mobile": "workspace:*", "@object-ui/permissions": "workspace:*", "@object-ui/plugin-editor": "workspace:*", "@object-ui/providers": "workspace:*", diff --git a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx index 2289fa10ff3..c79be4160d6 100644 --- a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -14,6 +14,12 @@ * OrganizationsPage), plus shortcuts to manage members / create a workspace. * - No org context at all: renders nothing. * + * Inside the ObjectStack mobile shell (`@object-ui/mobile`), when the shell + * announces `showConnectionSwitcher`, the same button opens the shell's native + * switcher instead of the dropdown: it lists the saved organizations and + * company servers the shell holds, which the web menu cannot see. In a plain + * browser, or a shell without the capability, the dropdown opens as before. + * * Under `group` tenancy posture (ADR-0105) the switcher's meaning changes: * the active organization is only the WRITE target — reads span every * organization the member belongs to — so the dropdown labels it as the @@ -25,6 +31,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '@object-ui/auth'; import type { AuthOrganization } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; +import { nativeHostCan, nativeShowConnectionSwitcher, useNativeHost } from '@object-ui/mobile'; import { DropdownMenu, DropdownMenuTrigger, @@ -45,6 +52,7 @@ export function WorkspaceSwitcher() { const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth(); const [multiOrgDisabled, setMultiOrgDisabled] = useState(false); const isGroupPosture = useTenancyPosture() === 'group'; + const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher'); useEffect(() => { let cancelled = false; @@ -100,15 +108,42 @@ export function WorkspaceSwitcher() { }); }; - return ( - - + + {current.name} + + + ); + + // The shell's sheet replaces the web menu; switching there is the shell's + // own flow, so nothing is sent from here. + if (nativeSwitcher) { + return ( + + ); + } + + return ( + + + {triggerContent} diff --git a/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx new file mode 100644 index 00000000000..3f9a0dbef88 --- /dev/null +++ b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * WorkspaceSwitcher inside the ObjectStack mobile shell. + * + * When the shell's bridge (`window.ObjectStackNative`, read through + * `@object-ui/mobile`) announces `showConnectionSwitcher`, the header's + * organization button opens the shell's native switcher and the web menu does + * not open. With no bridge, or a bridge without that capability, the web menu + * opens exactly as before. The dropdown is the real one, opened by a real + * click, so "the web menu opens" is observed, not assumed. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), +})); + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal>()), + useNavigate: () => vi.fn(), +})); + +let authState: Record; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => authState, +})); + +import { NATIVE_HOST_GLOBAL } from '@object-ui/mobile'; +import { WorkspaceSwitcher } from '../WorkspaceSwitcher'; + +const ORGS = [ + { id: 'org_a', name: 'Plant A', slug: 'plant-a' }, + { id: 'org_b', name: 'Plant B', slug: 'plant-b' }, +]; + +const BASE_METHODS = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer']; + +function installBridge(capabilities: string[]) { + const showConnectionSwitcher = vi.fn(async () => null); + (window as unknown as Record)[NATIVE_HOST_GLOBAL] = { + version: 1, + platform: 'ios', + appVersion: '2.1.0', + capabilities, + safeArea: { top: 47, bottom: 34, left: 0, right: 0 }, + has: (capability: string) => capabilities.includes(capability), + getPushToken: vi.fn(async () => null), + haptic: vi.fn(async () => null), + openExternal: vi.fn(async () => null), + navigate: vi.fn(async () => null), + resetServer: vi.fn(async () => null), + showConnectionSwitcher, + ready: vi.fn(), + routeChanged: vi.fn(), + }; + return showConnectionSwitcher; +} + +beforeEach(() => { + delete (window as unknown as Record)[NATIVE_HOST_GLOBAL]; + authState = { + organizations: ORGS, + activeOrganization: ORGS[0], + switchOrganization: vi.fn(), + getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true } }), + }; +}); + +afterEach(() => { + cleanup(); + delete (window as unknown as Record)[NATIVE_HOST_GLOBAL]; +}); + +describe('WorkspaceSwitcher in the mobile shell', () => { + it('with showConnectionSwitcher announced, the button opens the native switcher and no web menu', async () => { + const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).toHaveAttribute('data-native-switcher'); + expect(button).not.toHaveAttribute('aria-haspopup'); + + await userEvent.click(button); + + expect(showConnectionSwitcher).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.queryByText('Plant B')).not.toBeInTheDocument(); + expect(authState.switchOrganization).not.toHaveBeenCalled(); + }); + + it('control: with no bridge, the button opens the web organization menu', async () => { + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).not.toHaveAttribute('data-native-switcher'); + + await userEvent.click(button); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Switch organization')).toBeInTheDocument(); + expect(screen.getByText('Plant B')).toBeInTheDocument(); + }); + + it('control: a shell without the capability keeps the web menu and never calls the native switcher', async () => { + const showConnectionSwitcher = installBridge(BASE_METHODS); + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).not.toHaveAttribute('data-native-switcher'); + + await userEvent.click(button); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Plant B')).toBeInTheDocument(); + expect(showConnectionSwitcher).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx index 22696f6cf23..823ae821794 100644 --- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx +++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx @@ -20,6 +20,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { render, cleanup, act } from '@testing-library/react'; import { + NATIVE_CONNECTION_REMOVED_EVENT, NATIVE_HOST_GLOBAL, NATIVE_NAVIGATE_EVENT, getNativeHost, @@ -28,7 +29,9 @@ import { nativeHaptic, nativeNavigate, nativeOpenExternal, + nativeHostCan, nativeResetServer, + nativeShowConnectionSwitcher, postNativeReady, postNativeRouteChanged, resetNativeHostEventsForTests, @@ -42,7 +45,14 @@ import { useNativeHostRouting, } from '../useNativeHost'; -const ALL_METHODS: NativeHostMethod[] = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer']; +const ALL_METHODS: NativeHostMethod[] = [ + 'getPushToken', + 'haptic', + 'openExternal', + 'navigate', + 'resetServer', + 'showConnectionSwitcher', +]; function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackNative & { ready: Mock<() => void>; @@ -60,6 +70,7 @@ function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackN openExternal: vi.fn(async () => null), navigate: vi.fn(async () => null), resetServer: vi.fn(async () => null), + showConnectionSwitcher: vi.fn(async () => null), ready: vi.fn<() => void>(), routeChanged: vi.fn<(route: string) => void>(), }; @@ -189,6 +200,7 @@ describe('typed call wrappers feature-detect and never throw', () => { ['openExternal', () => nativeOpenExternal('https://example.com')], ['navigate', () => nativeNavigate('/home')], ['resetServer', () => nativeResetServer()], + ['showConnectionSwitcher', () => nativeShowConnectionSwitcher()], ]; it.each(calls)('%s resolves "unavailable" without a bridge', async (_method, call) => { @@ -219,6 +231,17 @@ describe('typed call wrappers feature-detect and never throw', () => { expect(bridge.openExternal).toHaveBeenCalledWith('mailto:a@example.com'); await nativeNavigate('/apps/crm'); expect(bridge.navigate).toHaveBeenCalledWith('/apps/crm'); + await expect(nativeShowConnectionSwitcher()).resolves.toEqual({ status: 'ok', value: null }); + expect(bridge.showConnectionSwitcher).toHaveBeenCalledTimes(1); + }); + + it('nativeHostCan answers what a wrapper would: announced and carried, nothing else', () => { + expect(nativeHostCan(null, 'showConnectionSwitcher')).toBe(false); + expect(nativeHostCan(fakeBridge(), 'showConnectionSwitcher')).toBe(true); + expect(nativeHostCan(fakeBridge(ALL_METHODS.filter((m) => m !== 'showConnectionSwitcher')), 'showConnectionSwitcher')).toBe(false); + const missing = fakeBridge() as unknown as Record; + delete missing.showConnectionSwitcher; + expect(nativeHostCan(missing as unknown as ObjectStackNative, 'showConnectionSwitcher')).toBe(false); }); it('a call the shell rejects resolves "failed" with the shell error code', async () => { @@ -360,6 +383,95 @@ describe('useNativeHostRouting', () => { }); }); +describe('connectionRemoved', () => { + function Router({ onConnectionRemoved }: { onConnectionRemoved?: () => Promise }) { + useNativeHostRouting({ route: '/home', navigate: () => {}, onConnectionRemoved }); + return null; + } + + /** The shell's event: a window Event carrying `waitUntil`, which records the promise. */ + function removedEvent(): { event: Event; held: Promise[] } { + const held: Promise[] = []; + const event = Object.assign(new Event(NATIVE_CONNECTION_REMOVED_EVENT), { + waitUntil: vi.fn((promise: Promise) => { + held.push(promise); + }), + }); + return { event, held }; + } + + it('with no unregister step, the shell is held by a promise that resolves at once', async () => { + install(fakeBridge()); + render(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(1); + await expect(held[0]).resolves.toBeUndefined(); + }); + + it('with an unregister step, the shell is held until it settles, and the step runs once', async () => { + install(fakeBridge()); + let finish!: () => void; + const unregister = vi.fn(() => new Promise((resolve) => (finish = resolve))); + render(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(1); + + let settled = false; + void held[0].then(() => (settled = true)); + await act(async () => {}); + expect(unregister).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + finish(); + await held[0]; + expect(settled).toBe(true); + }); + + it('the latest handler runs, without re-installing the listener per render', () => { + install(fakeBridge()); + const add = vi.spyOn(window, 'addEventListener'); + const first = vi.fn(async () => {}); + const second = vi.fn(async () => {}); + const { rerender } = render(); + rerender(); + expect(add.mock.calls.filter(([type]) => type === NATIVE_CONNECTION_REMOVED_EVENT)).toHaveLength(1); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + return held[0].then(() => { + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + }); + + it('an event without waitUntil is left alone', () => { + install(fakeBridge()); + const unregister = vi.fn(async () => {}); + render(); + window.dispatchEvent(new Event(NATIVE_CONNECTION_REMOVED_EVENT)); + expect(unregister).not.toHaveBeenCalled(); + }); + + it('control: outside shell mode no connectionRemoved listener is installed', () => { + const add = vi.spyOn(window, 'addEventListener'); + render(); + expect(add.mock.calls.some(([type]) => type === NATIVE_CONNECTION_REMOVED_EVENT)).toBe(false); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(0); + }); + + it('removes its listener on unmount', () => { + install(fakeBridge()); + const { unmount } = render(); + unmount(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(0); + }); +}); + describe('isInAppRoute', () => { it('accepts a single-slash path with query and hash, refuses escapes', () => { expect(isInAppRoute('/')).toBe(true); diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts index 8ebf03121fb..7fda02f208b 100644 --- a/ui/packages/mobile/src/index.ts +++ b/ui/packages/mobile/src/index.ts @@ -66,6 +66,7 @@ export { export { NATIVE_HOST_GLOBAL, NATIVE_NAVIGATE_EVENT, + NATIVE_CONNECTION_REMOVED_EVENT, getNativeHost, isInAppRoute, nativeGetPushToken, @@ -73,10 +74,13 @@ export { nativeOpenExternal, nativeNavigate, nativeResetServer, + nativeShowConnectionSwitcher, + nativeHostCan, postNativeReady, postNativeRouteChanged, type ObjectStackNative, type NativeCallResult, + type NativeConnectionRemovedEvent, type NativeHapticKind, type NativeHostMethod, type NativePushToken, diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts index ea517cfbffb..46a8fec6eb2 100644 --- a/ui/packages/mobile/src/nativeHost.ts +++ b/ui/packages/mobile/src/nativeHost.ts @@ -31,6 +31,22 @@ export const NATIVE_HOST_GLOBAL = 'ObjectStackNative'; */ export const NATIVE_NAVIGATE_EVENT = 'objectstack-native:navigate'; +/** + * The window event the shell dispatches to a loaded Console when the user + * removes this Console's connection in the shell. The page answers by calling + * `event.waitUntil(promise)` and settling the promise once it has unregistered + * this device's push token under its own session; the shell then clears the + * host's cookies. A page that does not settle within 10 s is recorded by the + * shell as having left the token registered. See `NativeConnectionRemovedEvent`. + */ +export const NATIVE_CONNECTION_REMOVED_EVENT = 'objectstack-native:connectionRemoved'; + +/** The `objectstack-native:connectionRemoved` event: a window `Event` carrying `waitUntil`. */ +export interface NativeConnectionRemovedEvent extends Event { + /** Hold the shell until `promise` settles (the shell waits at most 10 s). */ + waitUntil(promise: Promise): void; +} + /** Haptic feedback kinds the shell accepts. */ export type NativeHapticKind = | 'light' @@ -56,7 +72,13 @@ export interface NativeSafeArea { } /** The callable bridge methods. Each is announced as a capability of the same name. */ -export type NativeHostMethod = 'getPushToken' | 'haptic' | 'openExternal' | 'navigate' | 'resetServer'; +export type NativeHostMethod = + | 'getPushToken' + | 'haptic' + | 'openExternal' + | 'navigate' + | 'resetServer' + | 'showConnectionSwitcher'; /** `window.ObjectStackNative`, as the shell's bridge contract declares it. */ export interface ObjectStackNative { @@ -77,6 +99,11 @@ export interface ObjectStackNative { navigate(route: string): Promise; /** Forget the configured server and return to the shell's server picker. */ resetServer(): Promise; + /** + * Open the shell's native connection switcher: the saved organizations and + * company servers, plus an add action. + */ + showConnectionSwitcher(): Promise; /** Event: the page is interactive (the shell lifts its loading cover). */ ready(): void; @@ -112,8 +139,13 @@ export function getNativeHost(): ObjectStackNative | null { return host !== null && typeof host === 'object' ? (host as ObjectStackNative) : null; } -/** Whether the shell announces `capability` and the bridge carries the method. */ -function callable(host: ObjectStackNative | null, method: NativeHostMethod): host is ObjectStackNative { +/** + * Whether the shell announces `method` as a capability and the bridge carries + * it, so a typed call wrapper would reach the shell. Lets a control choose its + * native or web path before it renders; the wrapper still answers + * `unavailable` on its own. + */ +export function nativeHostCan(host: ObjectStackNative | null, method: NativeHostMethod): host is ObjectStackNative { if (!host || typeof host.has !== 'function' || typeof host[method] !== 'function') return false; try { return host.has(method) === true; @@ -127,7 +159,7 @@ async function callNative( invoke: (host: ObjectStackNative) => Promise, ): Promise> { const host = getNativeHost(); - if (!callable(host, method)) return { status: 'unavailable' }; + if (!nativeHostCan(host, method)) return { status: 'unavailable' }; try { return { status: 'ok', value: await invoke(host) }; } catch (error) { @@ -165,6 +197,11 @@ export function nativeResetServer(): Promise> { return callNative('resetServer', (host) => host.resetServer()); } +/** Open the shell's native connection switcher (organizations and company servers). */ +export function nativeShowConnectionSwitcher(): Promise> { + return callNative('showConnectionSwitcher', (host) => host.showConnectionSwitcher()); +} + // ── Events ────────────────────────────────────────────────────────────────── let readyPosted = false; diff --git a/ui/packages/mobile/src/useNativeHost.ts b/ui/packages/mobile/src/useNativeHost.ts index a73b2e10c3d..ee911cd4914 100644 --- a/ui/packages/mobile/src/useNativeHost.ts +++ b/ui/packages/mobile/src/useNativeHost.ts @@ -6,13 +6,15 @@ * LICENSE file in the root directory of this source tree. */ -import { useEffect, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useSyncExternalStore } from 'react'; import { + NATIVE_CONNECTION_REMOVED_EVENT, NATIVE_NAVIGATE_EVENT, getNativeHost, isInAppRoute, postNativeReady, postNativeRouteChanged, + type NativeConnectionRemovedEvent, type ObjectStackNative, } from './nativeHost.js'; @@ -92,11 +94,19 @@ export interface UseNativeHostRoutingOptions { route: string; /** Move the app's own router to an in-app route (no document reload). */ navigate: (route: string) => void; + /** + * Run when the shell removes this Console's connection + * (`objectstack-native:connectionRemoved`): unregister this device's push + * token under the Console's own session. The shell is held until the + * returned promise settles, at most 10 s. Omitted, the shell is released at + * once: there is no registered token to unregister. + */ + onConnectionRemoved?: () => Promise; } /** - * Wires the app's router to the native shell. Call it once, inside the router, - * from a component that renders beside the routes. In the shell it: + * Wires the app to the native shell. Call it once, inside the router, from a + * component that renders beside the routes. In the shell it: * * - posts `ready()` once per page load, after the first commit of the router * tree (or as soon as a late bridge appears); @@ -104,13 +114,25 @@ export interface UseNativeHostRoutingOptions { * - listens for the shell's `objectstack-native:navigate` event, moves the * router to `detail.route` when it is an in-app path, and cancels the event * so the shell does not reload the page. Anything else is left alone, and - * the shell's own full-load fallback runs. + * the shell's own full-load fallback runs; + * - answers the shell's `objectstack-native:connectionRemoved` event: calls + * `event.waitUntil()` with the promise `onConnectionRemoved` returns, or with + * one already resolved when it is omitted. A failed unregister settles the + * promise too; the shell then records the token as left registered. * * Outside the shell it does nothing and installs no listener. Returns the * bridge, or `null`. */ -export function useNativeHostRouting({ route, navigate }: UseNativeHostRoutingOptions): ObjectStackNative | null { +export function useNativeHostRouting({ + route, + navigate, + onConnectionRemoved, +}: UseNativeHostRoutingOptions): ObjectStackNative | null { const host = useNativeHost(); + // The latest handler, read when the event fires, so the listener does not + // re-install on every render that passes a new function. + const onConnectionRemovedRef = useRef(onConnectionRemoved); + onConnectionRemovedRef.current = onConnectionRemoved; useEffect(() => { if (host) postNativeReady(host); @@ -132,6 +154,18 @@ export function useNativeHostRouting({ route, navigate }: UseNativeHostRoutingOp return () => window.removeEventListener(NATIVE_NAVIGATE_EVENT, onNavigate); }, [host, navigate]); + useEffect(() => { + if (!host) return undefined; + const onRemoved = (event: Event) => { + const { waitUntil } = event as Partial; + if (typeof waitUntil !== 'function') return; + const handler = onConnectionRemovedRef.current; + waitUntil.call(event, handler ? Promise.resolve().then(handler) : Promise.resolve()); + }; + window.addEventListener(NATIVE_CONNECTION_REMOVED_EVENT, onRemoved); + return () => window.removeEventListener(NATIVE_CONNECTION_REMOVED_EVENT, onRemoved); + }, [host]); + return host; } From 19e1a531587ef1b11f0206f6b8295cbd3ddc5448 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:30:23 +0000 Subject: [PATCH 2/4] fix(mobile): keep the connectionRemoved handler ref out of render Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTVYu9vDSx28X2zi7h --- ui/packages/mobile/src/useNativeHost.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/mobile/src/useNativeHost.ts b/ui/packages/mobile/src/useNativeHost.ts index ee911cd4914..f761e20969b 100644 --- a/ui/packages/mobile/src/useNativeHost.ts +++ b/ui/packages/mobile/src/useNativeHost.ts @@ -132,7 +132,9 @@ export function useNativeHostRouting({ // The latest handler, read when the event fires, so the listener does not // re-install on every render that passes a new function. const onConnectionRemovedRef = useRef(onConnectionRemoved); - onConnectionRemovedRef.current = onConnectionRemoved; + useEffect(() => { + onConnectionRemovedRef.current = onConnectionRemoved; + }, [onConnectionRemoved]); useEffect(() => { if (host) postNativeReady(host); From da12cc51e2f92a1fda6a446f98357e7ee18961ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:06:11 +0000 Subject: [PATCH 3/4] docs(mobile): name the connection switcher wrapper and the connectionRemoved answer Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTVYu9vDSx28X2zi7h --- ui/packages/mobile/README.md | 18 +++++++++++++----- ui/packages/mobile/src/useNativeHost.ts | 4 ++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md index 5669c0f6a90..7b32b6d51ef 100644 --- a/ui/packages/mobile/README.md +++ b/ui/packages/mobile/README.md @@ -330,15 +330,23 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`, change, and the shell's cancelable `objectstack-native:navigate` window event moves the router (in-app paths only) instead of reloading the page. Outside the shell it installs nothing. +- The same hook answers the shell's `objectstack-native:connectionRemoved` window + event (`NATIVE_CONNECTION_REMOVED_EVENT`), sent when the user removes this + connection in the shell: it calls `event.waitUntil()` with the promise its optional + `onConnectionRemoved` returns (unregister the device's push token under the page's + own session), or with one already resolved when the option is omitted. - `ready()` means "the page has painted its own UI", which may be a loading fallback, such as an auth guard's spinner while the session is read. The shell lifts its loading cover on it, so the cover hands over to that spinner instead of staying up through the session round-trip. -- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and - `nativeResetServer` check `has(capability)` and that the method exists before - calling. They never throw: each resolves `{ status: 'unavailable' }` without the - capability, `{ status: 'ok', value }` on success, or - `{ status: 'failed', code, message }` when the shell refuses the call. +- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate`, + `nativeResetServer` and `nativeShowConnectionSwitcher` check `has(capability)` and + that the method exists before calling. They never throw: each resolves + `{ status: 'unavailable' }` without the capability, `{ status: 'ok', value }` on + success, or `{ status: 'failed', code, message }` when the shell refuses the call. +- `nativeHostCan(host, method)` is that same check, for a control that picks its + native or web path before it renders (the header's organization button opens the + shell's switcher this way). ```tsx import { nativeHaptic } from '@object-ui/mobile'; diff --git a/ui/packages/mobile/src/useNativeHost.ts b/ui/packages/mobile/src/useNativeHost.ts index 74013d82d97..2479544399a 100644 --- a/ui/packages/mobile/src/useNativeHost.ts +++ b/ui/packages/mobile/src/useNativeHost.ts @@ -123,8 +123,8 @@ export interface UseNativeHostRoutingOptions { * the shell's own full-load fallback runs; * - answers the shell's `objectstack-native:connectionRemoved` event: calls * `event.waitUntil()` with the promise `onConnectionRemoved` returns, or with - * one already resolved when it is omitted. A failed unregister settles the - * promise too; the shell then records the token as left registered. + * one already resolved when it is omitted. An unregister that fails rejects + * that promise, so the shell is never held past the step itself. * * Outside the shell it does nothing and installs no listener. Returns the * bridge, or `null`. From c962ab9e1e85349f397f161a307dd6f7420ce861 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:55:28 +0000 Subject: [PATCH 4/4] feat(app-shell): the native switcher button renders at one membership too In the mobile shell the sheet lists the shell's connections across company servers, so the button no longer waits for a second membership on this server; the read-only organization indicator steps aside so the header draws the organization once. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTVYu9vDSx28X2zi7h --- .../29458-native-connection-switcher.md | 2 +- .../layout/CurrentOrganizationIndicator.tsx | 9 ++ .../src/layout/WorkspaceSwitcher.tsx | 75 +++++++++-------- ...Switcher.nativeConnectionSwitcher.test.tsx | 84 ++++++++++++++++++- 4 files changed, 132 insertions(+), 38 deletions(-) diff --git a/.changeset/29458-native-connection-switcher.md b/.changeset/29458-native-connection-switcher.md index ef9a3c0f95c..793d1292bfe 100644 --- a/.changeset/29458-native-connection-switcher.md +++ b/.changeset/29458-native-connection-switcher.md @@ -10,7 +10,7 @@ Metadata-protocol: none The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both: -- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In a plain browser, or in a shell that does not announce the capability, the button opens the web menu as before. +- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In the shell the button renders whenever there is an active organization, at one membership too, since the sheet lists the shell's connections across company servers; `CurrentOrganizationIndicator` then renders nothing, so the organization is drawn once. In a plain browser, or in a shell that does not announce the capability, both render as before. - **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists. `@object-ui/mobile` adds `showConnectionSwitcher` to `NativeHostMethod` and, as an optional method (an older shell does not carry it), to `ObjectStackNative`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`. diff --git a/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx b/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx index 0166a61a935..a89e9b98187 100644 --- a/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx +++ b/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx @@ -33,6 +33,10 @@ * `@objectstack/spec/security`, which measured +237 KB on the eager chunk. That * file's docblock carries the measurement and the reasoning. * + * Inside the mobile shell, when it announces `showConnectionSwitcher`, this + * renders nothing: `WorkspaceSwitcher` then draws the same badge and name, at + * the same widths, as the button that opens the shell's native switcher. + * * An absent or unrecognized posture (older server, config fetch still in * flight or failed) renders nothing, matching how every other posture-keyed * affordance in this repo fails toward today's rendering. @@ -40,6 +44,7 @@ import { useAuth } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; +import { nativeHostCan, useNativeHost } from '@object-ui/mobile'; import { OrgBadge } from './OrgBadge.js'; import { postureHasOrgWall, useTenancyPosture } from '../hooks/useTenancyPosture.js'; @@ -47,6 +52,10 @@ export function CurrentOrganizationIndicator() { const { t } = useObjectTranslation(); const { organizations, activeOrganization } = useAuth(); const posture = useTenancyPosture(); + const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher'); + + // The shell's switcher button carries the organization instead. + if (nativeSwitcher) return null; // No organization wall in force ⇒ the top bar stays organization-silent, // exactly as it is today. Also covers the not-yet-resolved config. diff --git a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx index c79be4160d6..f9e435e4b40 100644 --- a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -4,9 +4,10 @@ * Header-left organization (workspace) switcher — the standard place users * expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style). * - * - Single-org users (the vast majority): render NOTHING. With one org there is - * nothing to switch to, so a dropdown here would control nothing. The - * switcher is a multi-org affordance. Where a tenancy wall IS in force the + * - Single-org users (the vast majority): render NOTHING on the web. With one + * org there is nothing to switch to, so a dropdown here would control + * nothing. The web switcher is a multi-org affordance (the mobile shell's + * native switcher, below, is not). Where a tenancy wall IS in force the * org name still matters at one membership — `CurrentOrganizationIndicator` * renders it read-only there (objectui#5287), without a control. * - Multi-org users: the active org name + a dropdown to switch orgs inline @@ -15,10 +16,12 @@ * - No org context at all: renders nothing. * * Inside the ObjectStack mobile shell (`@object-ui/mobile`), when the shell - * announces `showConnectionSwitcher`, the same button opens the shell's native - * switcher instead of the dropdown: it lists the saved organizations and - * company servers the shell holds, which the web menu cannot see. In a plain - * browser, or a shell without the capability, the dropdown opens as before. + * announces `showConnectionSwitcher`, the button opens the shell's native + * switcher instead of the dropdown, and it renders whenever there is an active + * organization, at any membership count: the sheet lists the connections the + * shell holds across company servers, which this server's membership list + * cannot count. In a plain browser, or a shell without the capability, the + * rules above apply unchanged. * * Under `group` tenancy posture (ADR-0105) the switcher's meaning changes: * the active organization is only the WRITE target — reads span every @@ -79,35 +82,6 @@ export function WorkspaceSwitcher() { // rather than an empty switcher or a guessed name. if (!current) return null; - // Single-org: render nothing. With only one org there's nothing to switch to, - // so a dropdown here would be a control that controls nothing. The switcher - // appears only once a second org exists. On a walled deployment the org NAME - // is still load-bearing context at one membership — `CurrentOrganizationIndicator` - // renders it read-only for exactly this case (objectui#5287); this rule is - // unchanged. - if (orgList.length <= 1) return null; - - const handleSwitch = (org: AuthOrganization) => { - if (org.id === current.id) return; - // objectui#13231 — ask BEFORE `set-active` is sent. Asking at the reload - // would be too late: the server has already switched, and the page holding - // the edits reloads itself for the new organization (dropping them, and its - // leave prompt with them) before the document load starts. On Cancel - // nothing is sent: the organization, the URL and the edits stay. - confirmUnsavedLeave(async () => { - try { - await switchOrganization(org.id); - // switchOrganization only updates state; reload to the console root so the - // new active org propagates to every data scope app-wide AND - // RootLandingRedirect resolves the right landing (single-app workspace → - // that app, not the redundant launcher). Same as OrganizationsPage. - window.location.href = resolveRootUrl(); - } catch (err) { - console.error('[WorkspaceSwitcher] switch failed', err); - } - }); - }; - const triggerClassName = 'ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground'; const triggerContent = ( @@ -140,6 +114,35 @@ export function WorkspaceSwitcher() { ); } + // Single-org: render nothing. With only one org there's nothing to switch to, + // so a dropdown here would be a control that controls nothing. The switcher + // appears only once a second org exists. On a walled deployment the org NAME + // is still load-bearing context at one membership — `CurrentOrganizationIndicator` + // renders it read-only for exactly this case (objectui#5287); this rule is + // unchanged. + if (orgList.length <= 1) return null; + + const handleSwitch = (org: AuthOrganization) => { + if (org.id === current.id) return; + // objectui#13231 — ask BEFORE `set-active` is sent. Asking at the reload + // would be too late: the server has already switched, and the page holding + // the edits reloads itself for the new organization (dropping them, and its + // leave prompt with them) before the document load starts. On Cancel + // nothing is sent: the organization, the URL and the edits stay. + confirmUnsavedLeave(async () => { + try { + await switchOrganization(org.id); + // switchOrganization only updates state; reload to the console root so the + // new active org propagates to every data scope app-wide AND + // RootLandingRedirect resolves the right landing (single-app workspace → + // that app, not the redundant launcher). Same as OrganizationsPage. + window.location.href = resolveRootUrl(); + } catch (err) { + console.error('[WorkspaceSwitcher] switch failed', err); + } + }); + }; + return ( diff --git a/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx index 3f9a0dbef88..5e16af21092 100644 --- a/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx @@ -9,11 +9,17 @@ * not open. With no bridge, or a bridge without that capability, the web menu * opens exactly as before. The dropdown is the real one, opened by a real * click, so "the web menu opens" is observed, not assumed. + * + * In the shell the button renders at ONE membership too: the sheet lists the + * shell's connections across servers, which this server's membership count + * cannot see. On a walled deployment `CurrentOrganizationIndicator` then steps + * aside, so the header carries the organization once, with the name shown at + * the same widths the indicator showed it. */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; vi.mock('@object-ui/i18n', async (importOriginal) => ({ @@ -36,6 +42,8 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ import { NATIVE_HOST_GLOBAL } from '@object-ui/mobile'; import { WorkspaceSwitcher } from '../WorkspaceSwitcher'; +import { CurrentOrganizationIndicator } from '../CurrentOrganizationIndicator'; +import { useTenancyPosture } from '../../hooks/useTenancyPosture'; const ORGS = [ { id: 'org_a', name: 'Plant A', slug: 'plant-a' }, @@ -124,3 +132,77 @@ describe('WorkspaceSwitcher in the mobile shell', () => { expect(showConnectionSwitcher).not.toHaveBeenCalled(); }); }); + +describe('WorkspaceSwitcher in the mobile shell at one membership (walled deployment)', () => { + /** AppHeader's pair, in its order, plus the resolved posture to wait on. */ + function PostureProbe() { + return {String(useTenancyPosture() ?? 'unresolved')}; + } + function mountHeaderPair() { + return render( + <> + + + + , + ); + } + + beforeEach(() => { + authState = { + organizations: [ORGS[0]], + activeOrganization: ORGS[0], + switchOrganization: vi.fn(), + getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true, tenancyPosture: 'isolated' } }), + }; + }); + + /** The name span of whichever surface carries the organization. */ + function nameSpan(container: HTMLElement): HTMLElement { + const spans = Array.from(container.querySelectorAll('span')).filter((el) => el.textContent === ORGS[0].name); + expect(spans).toHaveLength(1); + return spans[0] as HTMLElement; + } + + it('with the capability, the native button renders, opens the sheet, and the read-only indicator steps aside', async () => { + const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + const { container } = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated')); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).toHaveAttribute('data-native-switcher'); + expect(screen.queryByTestId('current-organization-indicator')).not.toBeInTheDocument(); + // The organization is drawn once. + expect(nameSpan(container).closest('[data-testid="workspace-switcher"]')).toBe(button); + + await userEvent.click(button); + expect(showConnectionSwitcher).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('the native button shows the name at the same widths the indicator did', async () => { + const plain = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + const indicatorNameClass = nameSpan(plain.container).className; + plain.unmount(); + + installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + const shell = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated')); + expect(nameSpan(shell.container).className).toBe(indicatorNameClass); + }); + + it('control: with no bridge, one membership draws no switcher and the read-only indicator, as today', async () => { + mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument(); + }); + + it('control: a shell without the capability keeps today\'s single-membership rendering', async () => { + const showConnectionSwitcher = installBridge(BASE_METHODS); + mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument(); + expect(showConnectionSwitcher).not.toHaveBeenCalled(); + }); +});