From 92a6f43c6cdc3a74808c7e4d3ee265a6689fb240 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:15:55 +0000 Subject: [PATCH 1/4] fix(console): a native push route is hosted under an app, as the inbox hosts it The native navigate listener resolves the shell's route through the inbox's resolveNotificationTarget under the app the person is in or last had open, and the catch-all hosts an unmatched app-relative path under the default app instead of dropping it on Home. app-shell publishes the resolver and the console root-segment table from its root. Claude-Session: https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8 Co-Authored-By: Claude --- ui/apps/console/src/App.tsx | 35 ++++++-- .../console/src/components/UnmatchedRoute.tsx | 63 +++++++++++++++ ui/apps/console/src/nativeShellRoute.ts | 80 +++++++++++++++++++ ui/packages/app-shell/src/index.ts | 9 +++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 ui/apps/console/src/components/UnmatchedRoute.tsx create mode 100644 ui/apps/console/src/nativeShellRoute.ts diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index a64ee41c433..2751b8d2be5 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -12,8 +12,8 @@ * with extra `` children. */ -import { lazy, Suspense, useEffect, useMemo } from 'react'; -import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, Outlet, matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useNativeHostRouting } from '@object-ui/mobile'; import { AuthProvider, useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { UploadProvider, createObjectStackUploadAdapter } from '@object-ui/providers'; @@ -37,7 +37,8 @@ import { DefaultInvitationsPage, DefaultSettingsPage, DefaultAcceptInvitationPage, - RedirectWithSplash, + useMetadata, + useNavigationContext, } from '@object-ui/app-shell'; import { AppContent, ConsolePermissionsRequest, ConsolePermissionsScope } from './AppContent'; @@ -47,6 +48,8 @@ import { RootLandingRedirect } from './components/RootLandingRedirect'; import { ProtectedRoute } from './components/ProtectedRoute'; import { studioRoutes } from './components/StudioRoute'; import { SetupRoute } from './components/SetupRoute'; +import { UnmatchedRoute } from './components/UnmatchedRoute'; +import { nativeShellRouteTarget } from './nativeShellRoute'; import { FormPage } from './components/FormPage'; import { InternalFormRoute } from './components/InternalFormRoute'; import { MetadataHmrReloader } from './components/MetadataHmrReloader'; @@ -148,13 +151,31 @@ const BASENAME = resolveBasename(); * the page. `data-os-native-shell` on the root element names the platform, so * a stylesheet can tell shell mode apart. Without the bridge this renders * nothing and installs nothing. + * + * The shell's route is a push route, the notification's `actionUrl`, and is + * never navigated verbatim (objectstack#29750): it is hosted the way the inbox + * hosts that link, under the app the person is in or last had open + * (`nativeShellRouteTarget`). With no such app (a cold start), the route goes + * to the router as given and the catch-all's `UnmatchedRoute` hosts it under + * the default app once the app list is loaded. */ function NativeShellRouting() { const location = useLocation(); const navigate = useNavigate(); + const { currentAppName } = useNavigationContext(); + // Outside every `MetadataProvider`, so the empty list; see `nativeShellRouteTarget`. + const { apps } = useMetadata(); + const hostHint = currentAppName ?? matchPath('/apps/:appName/*', location.pathname)?.params.appName; + const navigateShellRoute = useCallback( + (route: string) => { + const path = nativeShellRouteTarget(route, hostHint, apps); + if (path !== null) navigate(path); + }, + [apps, hostHint, navigate], + ); const host = useNativeHostRouting({ route: `${location.pathname}${location.search}${location.hash}`, - navigate, + navigate: navigateShellRoute, }); const platform = host?.platform; useEffect(() => { @@ -486,7 +507,11 @@ export function App() { * `index` redirect above is deliberately NOT changed — it fires * under an already-painted organization layout, where covering the * screen with a splash would be the regression. */} - } /> + {/* objectstack#29750 — an app-relative path (a push route such as + * `/opportunity/123`, arriving as the first URL or handed over by + * `NativeShellRouting`) is hosted under an app the way the inbox + * hosts it; a stale Console link still redirects to `/`. */} + } /> diff --git a/ui/apps/console/src/components/UnmatchedRoute.tsx b/ui/apps/console/src/components/UnmatchedRoute.tsx new file mode 100644 index 00000000000..99e842624a5 --- /dev/null +++ b/ui/apps/console/src/components/UnmatchedRoute.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * UnmatchedRoute — element for the Console's catch-all ``. + * + * A path no route matches used to go straight to `/`. That is still the answer + * for a stale Console link (`/dev/x`, `/home/x`): see + * `isAppRelativeUnmatchedPath`. An APP-RELATIVE path is a record the person was + * sent to, and dropping it on Home loses it (objectstack#29750): a push route + * such as `/opportunity/123` arrives here as the page's first URL when the + * mobile shell re-creates its WebView at a held route, or full-loads it because + * no navigate listener answered; and the Console's own navigate listener hands + * a route over here when it cannot name the host app (a cold start, before any + * app opened). + * + * Such a path is hosted the way the inbox hosts a notification's link: + * `resolveNotificationTarget(path, resolveHostAppSegment(apps, currentAppName))` + * — the app the person is in or last had open, re-checked against the active + * apps, else the first active app. That needs the app list, so the hop runs + * under `ProtectedRoute` (`requireOrganization={false}`, like `/`, because it + * only redirects; the destination's route owns the organization gate) and + * waits for the list. A signed-out visitor goes to the login page first and + * comes back to the same path. An app list that did not load is no answer to + * host under; that case keeps the old redirect to `/`, whose landing resolver + * owns the retry. + */ +import { useLocation } from 'react-router-dom'; +import { + LoadingFallback, + RedirectWithSplash, + resolveHostAppSegment, + resolveNotificationTarget, + useMetadata, + useNavigationContext, +} from '@object-ui/app-shell'; +import { ProtectedRoute } from './ProtectedRoute'; +import { isAppListConclusive } from './RootLandingRedirect'; +import { isAppRelativeUnmatchedPath } from '../nativeShellRoute'; + +export function UnmatchedRoute() { + const location = useLocation(); + if (!isAppRelativeUnmatchedPath(location.pathname)) { + return ; + } + return ( + + + + ); +} + +function HostAppRelativeRedirect() { + const location = useLocation(); + const { apps, loading, getTypeStatus } = useMetadata(); + const { currentAppName } = useNavigationContext(); + if (loading) return ; + if (!isAppListConclusive(getTypeStatus?.('app'))) return ; + const target = resolveNotificationTarget( + `${location.pathname}${location.search}${location.hash}`, + resolveHostAppSegment(apps, currentAppName), + ); + return ; +} diff --git a/ui/apps/console/src/nativeShellRoute.ts b/ui/apps/console/src/nativeShellRoute.ts new file mode 100644 index 00000000000..70d9f6e884c --- /dev/null +++ b/ui/apps/console/src/nativeShellRoute.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a route the native shell hands the Console lands (objectstack#29750). + * + * A push delivery's `route` is the notification's `actionUrl`, the value the + * inbox serves (`packages/spec/src/api/push-delivery.zod.ts`): app-relative + * (`/opportunity/123`) or already a Console route (`/apps/crm/opportunity/123`). + * The contract says the tap is resolved "as the inbox does, never verbatim", + * and the inbox's resolver is `resolveNotificationTarget` with a host app + * segment. Navigated verbatim, `/opportunity/123` matched no route and the + * catch-all dropped the person on Home. + * + * Two sites resolve it, both through that one resolver: + * + * - the native navigate listener (`NativeShellRouting` in `App.tsx`), when it + * can name the host app: {@link nativeShellRouteTarget}; + * - the unmatched-route guard (`components/UnmatchedRoute.tsx`), for everything + * else: an app-relative path arriving as the page's first URL (the shell + * re-creates its WebView at a held route, or full-loads it when no listener + * answered), and a route the listener handed over because it could not name + * the app. {@link isAppRelativeUnmatchedPath} decides which unmatched paths + * are hosted. + * + * @module + */ +import { + CONSOLE_ROOT_SEGMENTS, + resolveHostAppSegment, + resolveNotificationTarget, +} from '@object-ui/app-shell'; + +/** The app records `resolveHostAppSegment` reads, derived from its signature. */ +type HostApps = Parameters[0]; + +/** + * The path the native navigate listener moves the router to, or `null` to stay + * where the person is. + * + * `hostHint` is the app the person is in or last had open: the shell's + * published `currentAppName`, else the `:appName` of the current URL, the same + * hint every inbox door passes. `apps` is whatever app list is in reach; the + * listener renders outside every `MetadataProvider`, so there it is the empty + * list, and `resolveHostAppSegment` then answers the hint itself (its step 3). + * + * - No hint: the route as given. Nothing in reach can name the default app + * (that needs the app list), so the route goes to the router unchanged and + * the unmatched-route guard, which waits for the list, hosts it there. A + * Console route (`/apps/…`, `/home`, …) simply matches its own route. + * - A hint: `resolveNotificationTarget(route, segment)`. A `route` answer is + * the path; `null` (no url) and `external` (a scheme or `//host`) are + * `null`, so nothing moves. Neither is reachable through the listener: the + * shell's event is only taken (and cancelled) for an `isInAppRoute` path, + * which is never blank and never carries a scheme or a second leading `/`. + */ +export function nativeShellRouteTarget( + route: string, + hostHint: string | null | undefined, + apps: HostApps, +): string | null { + if (!hostHint) return route; + const target = resolveNotificationTarget(route, resolveHostAppSegment(apps, hostHint)); + return target?.kind === 'route' ? target.path : null; +} + +/** + * Is an unmatched pathname app-relative, so the catch-all hosts it under an app + * instead of redirecting to `/`? + * + * Yes unless its first segment names a Console route served outside an app + * (`CONSOLE_ROOT_SEGMENTS`, the empty segment included) or is `apps`, which + * `resolveNotificationTarget` passes through unchanged and so has nothing to + * host. Those keep the catch-all's redirect to `/`: an unknown `/dev/x` or + * `/home/x` is a stale Console link, not a record. + */ +export function isAppRelativeUnmatchedPath(pathname: string): boolean { + if (!pathname.startsWith('/')) return false; + const firstSegment = pathname.slice(1).split('/', 1)[0]; + return firstSegment !== 'apps' && !CONSOLE_ROOT_SEGMENTS.has(firstSegment); +} diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index b37895fdbce..05aaa0c33cc 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -94,6 +94,15 @@ export { // (objectui#4109 / PR #4279), which is the "two readers of one prose contract" // shape #3367 / #3842 record. `./utils/appRoute` is the contract. export { resolveHostAppSegment, appRouteSegment, filterActiveApps } from './utils/index.js'; +// objectstack#29750 — the inbox's notification-link resolver, and the table of +// console routes served outside an app. The console's native-shell navigate +// listener and its unmatched-route guard host an app-relative push route +// through the SAME resolver every inbox door uses, so a push tap and the inbox +// row open the same screen; re-deriving either here would be a second reader +// of one contract. +export { resolveNotificationTarget } from './utils/index.js'; +export type { NotificationTarget } from './utils/index.js'; +export { CONSOLE_ROOT_SEGMENTS } from './utils/hostInConsolePath.js'; // objectui#5178 — the Approval Center's timeline reads the same `via_override` // verdict the record page's approval panel does. export { From c00ef9e78943c6cc1892692193cacd8896c1323b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:17:22 +0000 Subject: [PATCH 2/4] test(console): pin the push route hosting in the listener and the catch-all Claude-Session: https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8 Co-Authored-By: Claude --- .changeset/29750-native-push-route-hosted.md | 20 +++ .../App.nativeShellMode-29039.test.tsx | 154 +++++++++++++++++- .../__tests__/nativeShellRoute-29750.test.ts | 61 +++++++ 3 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 .changeset/29750-native-push-route-hosted.md create mode 100644 ui/apps/console/src/__tests__/nativeShellRoute-29750.test.ts diff --git a/.changeset/29750-native-push-route-hosted.md b/.changeset/29750-native-push-route-hosted.md new file mode 100644 index 00000000000..c2c740687b0 --- /dev/null +++ b/.changeset/29750-native-push-route-hosted.md @@ -0,0 +1,20 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(console): a push route from the mobile shell opens the record, hosted under an app the way the inbox hosts it + +Clause-②: no +Metadata-protocol: none + +A push delivery carries the notification's link as `route`: app-relative (`/opportunity/123`) or already a Console route (`/apps/crm/opportunity/123`). The Console's listener for the mobile shell's navigate event moved its router to that route as written, so `/opportunity/123` matched no route and the person landed on Home instead of the record. + +What changes: + +- The listener resolves the route with the same resolver the inbox bell and Home use for that notification. An app-relative route is hosted under the app the person is in or last had open; an `/apps/...` route passes through unchanged. +- With no app open yet (a tap that starts the app), the route goes to the router as given, and the Console's unmatched-route page hosts it under the default app once the app list has loaded: the app the person last had open while it is still active, else the first active app. The same page handles an app-relative path that arrives as the page's first URL, which the shell sends when it re-creates its web view at a held route or when no listener answered. +- A path whose first segment names a Console route served outside an app (`/home`, `/dev`, `/studio`, ...) still redirects to `/`, as before. A path such as `/foo/bar` that names no Console route now opens `/apps//foo/bar` instead of Home. +- `@object-ui/app-shell` exports `resolveNotificationTarget`, its `NotificationTarget` type and `CONSOLE_ROOT_SEGMENTS` from the package root. + +What to do: nothing. diff --git a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx index b2ffa2b75b9..7bb9429a656 100644 --- a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx +++ b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx @@ -33,10 +33,19 @@ import { } from '../../../../packages/mobile/src/nativeHost'; import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost'; -const { passthrough, stub } = vi.hoisted(() => { +const { passthrough, stub, shellState } = vi.hoisted(() => { const passthrough = ({ children }: { children?: ReactNode }) => <>{children}; const stub = (testid: string) => () =>
; - return { passthrough, stub }; + // objectstack#29750: what the shell's navigation context and the metadata + // provider answer. The defaults are what this file's other cases read + // without either provider: no current app, an empty app list, not loading. + const shellState = { + currentAppName: undefined as string | undefined, + apps: [] as Array>, + loading: false, + appStatus: undefined as 'idle' | 'loading' | 'ready' | 'error' | undefined, + }; + return { passthrough, stub, shellState }; }); vi.mock('@object-ui/app-shell', async (importOriginal) => ({ @@ -44,7 +53,22 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => ({ ConsoleShell: passthrough, ConsoleToaster: () => null, LoadingScreen: stub('loading-screen'), - RedirectWithSplash: stub('redirect-with-splash'), + // Records where it would go, so the catch-all's answer is readable. + RedirectWithSplash: ({ to }: { to: string }) =>
, + LoadingFallback: stub('loading-fallback'), + useNavigationContext: () => ({ + context: 'app', + setContext: () => {}, + currentAppName: shellState.currentAppName, + setCurrentAppName: () => {}, + recordTitle: undefined, + setRecordTitle: () => {}, + }), + useMetadata: () => ({ + apps: shellState.apps, + loading: shellState.loading, + getTypeStatus: (type: string) => (type === 'app' ? shellState.appStatus : undefined), + }), RequireAiSurface: passthrough, SystemRedirect: () => null, DefaultHomeLayout: passthrough, @@ -134,6 +158,10 @@ function dispatchNavigate(route: unknown): CustomEvent { beforeEach(() => { resetNativeHostEventsForTests(); resetNativeHostWatchForTests(); + shellState.currentAppName = undefined; + shellState.apps = []; + shellState.loading = false; + shellState.appStatus = undefined; window.history.pushState({}, '', '/login'); }); @@ -188,3 +216,123 @@ describe('control: the Console in a browser behaves as today', () => { expect(window.location.pathname).toBe('/login'); }); }); + +/** + * objectstack#29750 — the shell's route is a push route, the notification's + * `actionUrl`, and the push contract (`packages/spec/src/api/push-delivery.zod.ts`) + * says it is resolved "as the inbox does, never verbatim": app-relative paths + * are hosted under `/apps/APP/`, an `/apps/…` route passes through. Navigated + * verbatim, `/opportunity/123` matched no route and the catch-all sent the + * person to Home. + */ +describe('a push route from the shell is hosted the way the inbox hosts it', () => { + it('an app-relative route is hosted under the app in the URL', async () => { + installBridge(); + window.history.pushState({}, '', '/apps/crm/opportunity'); + render(); + await screen.findByTestId('app-content'); + const event = dispatchNavigate('/opportunity/123?tab=activity#notes'); + expect(event.defaultPrevented).toBe(true); + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`) + .toBe('/apps/crm/opportunity/123?tab=activity#notes'); + }); + + it('the app the shell last published wins over the URL, as for the inbox bell', async () => { + installBridge(); + shellState.currentAppName = 'sales'; + window.history.pushState({}, '', '/home'); + render(); + await screen.findByTestId('home-page'); + dispatchNavigate('/opportunity/123'); + expect(window.location.pathname).toBe('/apps/sales/opportunity/123'); + }); + + it('a Console route under /apps passes through unchanged', async () => { + installBridge(); + window.history.pushState({}, '', '/apps/crm/opportunity'); + render(); + await screen.findByTestId('app-content'); + const event = dispatchNavigate('/apps/x/y/1'); + expect(event.defaultPrevented).toBe(true); + expect(window.location.pathname).toBe('/apps/x/y/1'); + }); + + it('with no app open, the catch-all hosts the handed-over route under the default app', async () => { + installBridge(); + shellState.apps = [{ name: 'crm', active: true }]; + shellState.appStatus = 'ready'; + render(); + await screen.findByTestId('login-page'); + const event = dispatchNavigate('/opportunity/123'); + expect(event.defaultPrevented).toBe(true); + expect(window.location.pathname).toBe('/opportunity/123'); + expect((await screen.findByTestId('redirect-with-splash')).getAttribute('data-to')) + .toBe('/apps/crm/opportunity/123'); + }); +}); + +/** + * objectstack#29750 — the same push route can arrive as the page's FIRST URL: + * the mobile shell starts a re-created WebView at a held route, and falls back + * to `location.assign` of it when no navigate listener answered. The + * catch-all hosts an app-relative path like the inbox; a stale Console link + * keeps the old redirect to `/`. + */ +describe('the catch-all hosts an app-relative first URL under an app', () => { + function catchAllTarget(): Promise { + return screen.findByTestId('redirect-with-splash').then((el) => el.getAttribute('data-to')); + } + + it('hosts it under the first active app, query and hash kept', async () => { + shellState.apps = [{ name: 'gone', active: false }, { name: 'crm', active: true }]; + shellState.appStatus = 'ready'; + window.history.pushState({}, '', '/opportunity/123?tab=activity#notes'); + render(); + expect(await catchAllTarget()).toBe('/apps/crm/opportunity/123?tab=activity#notes'); + }); + + it('hosts it under the app the person last had open while that app is still active', async () => { + shellState.apps = [{ name: 'crm', active: true }, { name: 'sales', active: true }]; + shellState.appStatus = 'ready'; + shellState.currentAppName = 'sales'; + window.history.pushState({}, '', '/opportunity/123'); + render(); + expect(await catchAllTarget()).toBe('/apps/sales/opportunity/123'); + }); + + it('waits for the app list before it answers', async () => { + shellState.loading = true; + window.history.pushState({}, '', '/opportunity/123'); + render(); + expect(await screen.findByTestId('loading-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('redirect-with-splash')).toBeNull(); + }); + + it('an app list that failed to load is no answer: the old redirect to / runs', async () => { + shellState.appStatus = 'error'; + window.history.pushState({}, '', '/opportunity/123'); + render(); + expect(await catchAllTarget()).toBe('/'); + }); + + it('control: a stale Console link still redirects to /', async () => { + shellState.apps = [{ name: 'crm', active: true }]; + shellState.appStatus = 'ready'; + for (const path of ['/dev/nope', '/home/nope', '/apps']) { + window.history.pushState({}, '', path); + render(); + expect(await catchAllTarget()).toBe('/'); + cleanup(); + } + }); + + it('control: a real top-level Console route is never rewritten', async () => { + shellState.apps = [{ name: 'crm', active: true }]; + shellState.appStatus = 'ready'; + window.history.pushState({}, '', '/home'); + render(); + expect(await screen.findByTestId('home-page')).toBeInTheDocument(); + expect(screen.queryByTestId('redirect-with-splash')).toBeNull(); + expect(window.location.pathname).toBe('/home'); + }); +}); diff --git a/ui/apps/console/src/__tests__/nativeShellRoute-29750.test.ts b/ui/apps/console/src/__tests__/nativeShellRoute-29750.test.ts new file mode 100644 index 00000000000..2fab8701c5f --- /dev/null +++ b/ui/apps/console/src/__tests__/nativeShellRoute-29750.test.ts @@ -0,0 +1,61 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29750 — where a route the native shell hands the Console lands, + * at the unit level. The wiring into the real router is pinned in + * `App.nativeShellMode-29039.test.tsx`; this file holds the two answers that + * no listener input can reach (the resolver's `null` and `external`), because + * `isInAppRoute` refuses a blank route and any scheme or `//host` before the + * Console ever sees it, and the unmatched-path predicate's table. + */ + +import { describe, expect, it } from 'vitest'; +import { isAppRelativeUnmatchedPath, nativeShellRouteTarget } from '../nativeShellRoute'; + +describe('nativeShellRouteTarget', () => { + it('hosts an app-relative route under the hinted app', () => { + expect(nativeShellRouteTarget('/opportunity/123', 'crm', [])).toBe('/apps/crm/opportunity/123'); + }); + + it('passes a Console route under /apps through', () => { + expect(nativeShellRouteTarget('/apps/x/y/1', 'crm', [])).toBe('/apps/x/y/1'); + }); + + it('re-checks the hint against an app list in reach, as the inbox does', () => { + const apps = [{ name: 'crm', active: true }, { name: 'old', active: false }]; + expect(nativeShellRouteTarget('/opportunity/123', 'old', apps)).toBe('/apps/crm/opportunity/123'); + }); + + it('with no hint, hands the route to the router unchanged', () => { + expect(nativeShellRouteTarget('/opportunity/123', undefined, [])).toBe('/opportunity/123'); + }); + + it('a blank route resolves to nothing: no navigation', () => { + expect(nativeShellRouteTarget('', 'crm', [])).toBeNull(); + expect(nativeShellRouteTarget(' ', 'crm', [])).toBeNull(); + }); + + it('an off-console route resolves to nothing for the router: no navigation', () => { + expect(nativeShellRouteTarget('https://evil.example/x', 'crm', [])).toBeNull(); + expect(nativeShellRouteTarget('//evil.example/x', 'crm', [])).toBeNull(); + }); +}); + +describe('isAppRelativeUnmatchedPath', () => { + it('a path whose first segment names no Console route is app-relative', () => { + expect(isAppRelativeUnmatchedPath('/opportunity/123')).toBe(true); + expect(isAppRelativeUnmatchedPath('/showcase_task')).toBe(true); + }); + + it('a Console route served outside an app, the landing and /apps are not', () => { + for (const path of ['/', '/home/x', '/dev/nope', '/studio/x/y', '/forms/x/y', '/apps', '/apps/']) { + expect(isAppRelativeUnmatchedPath(path), path).toBe(false); + } + }); +}); From a150b5c223561599b02dec613c20a17284b0fd73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:27:00 +0000 Subject: [PATCH 3/4] test(console): the agent-chat route-table harness inherits the landing module's predicate Claude-Session: https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8 Co-Authored-By: Claude --- .changeset/29750-native-push-route-hosted.md | 2 +- .../console/src/__tests__/App.agentChatLink-10640.test.tsx | 6 +++++- .../src/__tests__/App.nativeShellMode-29039.test.tsx | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/29750-native-push-route-hosted.md b/.changeset/29750-native-push-route-hosted.md index c2c740687b0..df199360670 100644 --- a/.changeset/29750-native-push-route-hosted.md +++ b/.changeset/29750-native-push-route-hosted.md @@ -14,7 +14,7 @@ What changes: - The listener resolves the route with the same resolver the inbox bell and Home use for that notification. An app-relative route is hosted under the app the person is in or last had open; an `/apps/...` route passes through unchanged. - With no app open yet (a tap that starts the app), the route goes to the router as given, and the Console's unmatched-route page hosts it under the default app once the app list has loaded: the app the person last had open while it is still active, else the first active app. The same page handles an app-relative path that arrives as the page's first URL, which the shell sends when it re-creates its web view at a held route or when no listener answered. -- A path whose first segment names a Console route served outside an app (`/home`, `/dev`, `/studio`, ...) still redirects to `/`, as before. A path such as `/foo/bar` that names no Console route now opens `/apps//foo/bar` instead of Home. +- A path whose first segment names a Console route served outside an app (`/home`, `/dev`, `/studio`, ...) still redirects to `/`, as before. A path such as `/foo/bar` that names no Console route now opens `/apps/APP/foo/bar` (APP being that default app) instead of Home. - `@object-ui/app-shell` exports `resolveNotificationTarget`, its `NotificationTarget` type and `CONSOLE_ROOT_SEGMENTS` from the package root. What to do: nothing. diff --git a/ui/apps/console/src/__tests__/App.agentChatLink-10640.test.tsx b/ui/apps/console/src/__tests__/App.agentChatLink-10640.test.tsx index 61e58936d3d..d1bccfc91f5 100644 --- a/ui/apps/console/src/__tests__/App.agentChatLink-10640.test.tsx +++ b/ui/apps/console/src/__tests__/App.agentChatLink-10640.test.tsx @@ -107,7 +107,11 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ // ── Everything else App.tsx imports but this card does not touch ───────── vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough })); -vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') })); +// objectstack#29750: the catch-all reads `isAppListConclusive` from here. +vi.mock('../components/RootLandingRedirect', async (importOriginal) => ({ + ...(await importOriginal>()), + RootLandingRedirect: stub('root-landing'), +})); vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') })); vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null })); vi.mock('../AppContent', () => ({ diff --git a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx index 7bb9429a656..6d2a25447c9 100644 --- a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx +++ b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx @@ -100,7 +100,11 @@ vi.mock('../AppContent', () => ({ ConsolePermissionsRequest: () => null, })); vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough })); -vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') })); +// objectstack#29750: the catch-all reads `isAppListConclusive` from here. +vi.mock('../components/RootLandingRedirect', async (importOriginal) => ({ + ...(await importOriginal>()), + RootLandingRedirect: stub('root-landing'), +})); vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') })); vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') })); vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') })); From a40710f7a3ac30e982556c2dfcdfeaf72341fc98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:45:53 +0000 Subject: [PATCH 4/4] fix(console): the push listener hosts only under the app its URL names, and the catch-all waits for a settled app list Measured live: a session-cached app list stops `loading` while its re-read still reads `loading`, so the catch-all redirected to Home; and hosting under `currentAppName` (an app name) made the app route re-address the page several times. The listener now hands a route on whenever the URL names no app. Claude-Session: https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8 Co-Authored-By: Claude --- ui/apps/console/src/App.tsx | 15 ++++++------ .../App.nativeShellMode-29039.test.tsx | 24 ++++++++++++++++--- .../console/src/components/UnmatchedRoute.tsx | 17 ++++++++----- ui/apps/console/src/nativeShellRoute.ts | 21 +++++++++------- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index 2751b8d2be5..de1d9967c83 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -38,7 +38,6 @@ import { DefaultSettingsPage, DefaultAcceptInvitationPage, useMetadata, - useNavigationContext, } from '@object-ui/app-shell'; import { AppContent, ConsolePermissionsRequest, ConsolePermissionsScope } from './AppContent'; @@ -154,18 +153,20 @@ const BASENAME = resolveBasename(); * * The shell's route is a push route, the notification's `actionUrl`, and is * never navigated verbatim (objectstack#29750): it is hosted the way the inbox - * hosts that link, under the app the person is in or last had open - * (`nativeShellRouteTarget`). With no such app (a cold start), the route goes - * to the router as given and the catch-all's `UnmatchedRoute` hosts it under - * the default app once the app list is loaded. + * hosts that link (`nativeShellRouteTarget`). Inside an app it goes under the + * app the URL names. Anywhere else (Home, a cold start) the route goes to the + * router as given, and the catch-all's `UnmatchedRoute` hosts it once the app + * list is loaded: under the app the person last had open while it is still + * active, else the first active app. The shell's published `currentAppName` is + * left to that guard because it is an app NAME, and only the app list turns a + * name into the address its route uses. */ function NativeShellRouting() { const location = useLocation(); const navigate = useNavigate(); - const { currentAppName } = useNavigationContext(); // Outside every `MetadataProvider`, so the empty list; see `nativeShellRouteTarget`. const { apps } = useMetadata(); - const hostHint = currentAppName ?? matchPath('/apps/:appName/*', location.pathname)?.params.appName; + const hostHint = matchPath('/apps/:appName/*', location.pathname)?.params.appName; const navigateShellRoute = useCallback( (route: string) => { const path = nativeShellRouteTarget(route, hostHint, apps); diff --git a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx index 6d2a25447c9..41bbaeb8fb3 100644 --- a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx +++ b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx @@ -241,14 +241,21 @@ describe('a push route from the shell is hosted the way the inbox hosts it', () .toBe('/apps/crm/opportunity/123?tab=activity#notes'); }); - it('the app the shell last published wins over the URL, as for the inbox bell', async () => { + it('away from an app, the catch-all hosts it under the app the person last had open', async () => { + // `currentAppName` is an app NAME; the catch-all turns it into the app's + // route segment with the app list, which the listener does not have. installBridge(); shellState.currentAppName = 'sales'; + shellState.apps = [{ name: 'crm', active: true }, { name: 'sales', active: true, _packageId: 'com.acme.sales' }]; + shellState.appStatus = 'ready'; window.history.pushState({}, '', '/home'); render(); await screen.findByTestId('home-page'); - dispatchNavigate('/opportunity/123'); - expect(window.location.pathname).toBe('/apps/sales/opportunity/123'); + const event = dispatchNavigate('/opportunity/123'); + expect(event.defaultPrevented).toBe(true); + expect(window.location.pathname).toBe('/opportunity/123'); + expect((await screen.findByTestId('redirect-with-splash')).getAttribute('data-to')) + .toBe('/apps/com.acme.sales/opportunity/123'); }); it('a Console route under /apps passes through unchanged', async () => { @@ -312,6 +319,17 @@ describe('the catch-all hosts an app-relative first URL under an app', () => { expect(screen.queryByTestId('redirect-with-splash')).toBeNull(); }); + it('waits while a session-cached app list is still being re-read', async () => { + // Measured live: the provider stops `loading` on the cached list while + // the app type's own status still reads `loading`. + shellState.apps = [{ name: 'crm', active: true }]; + shellState.appStatus = 'loading'; + window.history.pushState({}, '', '/opportunity/123'); + render(); + expect(await screen.findByTestId('loading-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('redirect-with-splash')).toBeNull(); + }); + it('an app list that failed to load is no answer: the old redirect to / runs', async () => { shellState.appStatus = 'error'; window.history.pushState({}, '', '/opportunity/123'); diff --git a/ui/apps/console/src/components/UnmatchedRoute.tsx b/ui/apps/console/src/components/UnmatchedRoute.tsx index 99e842624a5..b72f17cf325 100644 --- a/ui/apps/console/src/components/UnmatchedRoute.tsx +++ b/ui/apps/console/src/components/UnmatchedRoute.tsx @@ -19,10 +19,11 @@ * apps, else the first active app. That needs the app list, so the hop runs * under `ProtectedRoute` (`requireOrganization={false}`, like `/`, because it * only redirects; the destination's route owns the organization gate) and - * waits for the list. A signed-out visitor goes to the login page first and - * comes back to the same path. An app list that did not load is no answer to - * host under; that case keeps the old redirect to `/`, whose landing resolver - * owns the retry. + * waits until the list has settled (`isAppListConclusive`, the landing + * route's own reading). A signed-out visitor goes to the login page first and + * comes back to the same path. An app list that failed to load is no answer + * to host under; that case keeps the old redirect to `/`, whose landing + * resolver owns the retry. */ import { useLocation } from 'react-router-dom'; import { @@ -53,8 +54,12 @@ function HostAppRelativeRedirect() { const location = useLocation(); const { apps, loading, getTypeStatus } = useMetadata(); const { currentAppName } = useNavigationContext(); - if (loading) return ; - if (!isAppListConclusive(getTypeStatus?.('app'))) return ; + // The list is the answer only once it settled: a session-cached list keeps + // `loading` false while its re-read is still in flight (status `loading`), + // and hosting under it then could name an app the re-read drops. + const status = getTypeStatus?.('app'); + if (status === 'error') return ; + if (loading || !isAppListConclusive(status)) return ; const target = resolveNotificationTarget( `${location.pathname}${location.search}${location.hash}`, resolveHostAppSegment(apps, currentAppName), diff --git a/ui/apps/console/src/nativeShellRoute.ts b/ui/apps/console/src/nativeShellRoute.ts index 70d9f6e884c..ca47b41372e 100644 --- a/ui/apps/console/src/nativeShellRoute.ts +++ b/ui/apps/console/src/nativeShellRoute.ts @@ -37,16 +37,19 @@ type HostApps = Parameters[0]; * The path the native navigate listener moves the router to, or `null` to stay * where the person is. * - * `hostHint` is the app the person is in or last had open: the shell's - * published `currentAppName`, else the `:appName` of the current URL, the same - * hint every inbox door passes. `apps` is whatever app list is in reach; the - * listener renders outside every `MetadataProvider`, so there it is the empty - * list, and `resolveHostAppSegment` then answers the hint itself (its step 3). + * `hostHint` is the app the person is in: the `:appName` of the current + * `/apps/:appName/*` URL, already the segment its route is addressed by. + * `apps` is whatever app list is in reach; the listener renders outside every + * `MetadataProvider`, so there it is the empty list, and + * `resolveHostAppSegment` then answers the hint itself (its step 3). * - * - No hint: the route as given. Nothing in reach can name the default app - * (that needs the app list), so the route goes to the router unchanged and - * the unmatched-route guard, which waits for the list, hosts it there. A - * Console route (`/apps/…`, `/home`, …) simply matches its own route. + * - No hint (Home, a cold start): the route as given. Naming the app then + * takes the app list: the default app, or the shell's `currentAppName`, + * which is an app NAME that only the list turns into its route segment + * (measured: hosted under the name, the app route re-addresses the page + * several times before it settles). So the route goes to the router + * unchanged and the unmatched-route guard, which waits for the list, hosts + * it. A Console route (`/apps/…`, `/home`, …) simply matches its own route. * - A hint: `resolveNotificationTarget(route, segment)`. A `route` answer is * the path; `null` (no url) and `external` (a scheme or `//host`) are * `null`, so nothing moves. Neither is reachable through the listener: the