Skip to content

Commit b0ef25f

Browse files
fix(console): a native push route is hosted under an app, as the inbox hosts it (#29810)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRCP9FRcztKQfRmd9KXSgR1)_ Fixes #29750 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机 App 收到推送、用户点开时,通知里的链接(如 `/opportunity/123`)现在会像收件箱一样放进对应的应用里打开记录,不再落到首页;直接打开这种链接作为首个网址时也一样。 - **为什么改**:推送契约(#29218)规定点开推送要「按收件箱的方式解析,不能原样跳转」,而 Console 之前原样跳转,找不到路由就回首页,推送一上线(#29041)就会打不开记录。 - **风险与回滚**:只改 Console 前端路由;一个副作用是浏览器里打开一个不存在的、首段不是 Console 页面的网址(如 `/foo/bar`),现在进入默认应用并显示「页面未找到」,而不是回首页。无服务端、无数据改动,回滚即还原本 PR。 - **先看**:`ui/apps/console/src/components/UnmatchedRoute.tsx`(兜底路由何时等应用列表、何时仍回首页)、`ui/apps/console/src/App.tsx` 的 `NativeShellRouting`(推送监听)、`ui/apps/console/src/nativeShellRoute.ts`。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx apps/console/src/__tests__/nativeShellRoute-29750.test.ts`,应全部通过(15 + 8)。 - **需要你做的**:无,席位落地。 ## What changes The push contract (`packages/spec/src/api/push-delivery.zod.ts`, #29218) says a push `route` is the notification's `actionUrl`, app-relative (`/opportunity/123`) or a Console route (`/apps/crm/opportunity/123`), and that the tap resolves it "as the inbox does, never verbatim". The Console's native navigate listener navigated it verbatim, so `/opportunity/123` matched no route and the catch-all sent the person to Home. Two legs now host it, both through the inbox's own resolver, `resolveNotificationTarget`, with `resolveHostAppSegment`. Nothing is re-implemented. **Leg 1, the listener** (`NativeShellRouting` in `ui/apps/console/src/App.tsx`). It hands `useNativeHostRouting` a wrapped `navigate` (pure logic in the new `ui/apps/console/src/nativeShellRoute.ts`, `nativeShellRouteTarget`): - Inside an app, it hosts the route under the app the URL names (`/apps/:appName/*`, already the app's route segment). `/opportunity/123` on `/apps/crm/...` goes to `/apps/crm/opportunity/123`; `/apps/x/y/1` passes through. - Anywhere else (Home, or a cold start before any app opened), it hands the route to the router as given, and leg 2 hosts it. Naming the app there needs the app list, and the listener renders beside the routes, outside every `MetadataProvider`. The shell's published `currentAppName` is an app NAME; only the list turns it into its route segment. Measured live: hosting under the name (`/apps/crm_app/...` for an app addressed as `/apps/com.example.crm`) made the app route re-address the page four times before it settled. - The resolver's `null` (blank url) and `external` (scheme or `//host`) answers navigate nothing, and the event stays cancelled. Neither is reachable through the listener by construction: `isInAppRoute` in `@object-ui/mobile` refuses a blank route, a scheme and a second leading slash before the Console sees the event. Both are pinned at unit level. **Leg 2, the catch-all** (`path="*"`, new element `ui/apps/console/src/components/UnmatchedRoute.tsx`): - A path whose first segment is a Console route served outside an app (`CONSOLE_ROOT_SEGMENTS`, the table `hostInConsolePath` already keeps and the agent-chat route-table test pins against `App.tsx`) or `apps` keeps today's redirect to `/`. - Any other unmatched path goes under `ProtectedRoute requireOrganization={false}` (the same guard as `/`), waits until the app list has settled (`isAppListConclusive`, the landing route's own reading), and redirects to `resolveNotificationTarget(path + query + hash, resolveHostAppSegment(apps, currentAppName))`: the app the person last had open while it is still active, else the first active app. A failed app list keeps the old redirect to `/`, whose landing resolver owns the retry. A signed-out visitor goes through login and comes back to the same path. - This covers the three ways the mobile shell delivers a route as the page's FIRST URL (read on objectstack-ai/mobile `main` `ddcf2d5`, `lib/shell/console-load.ts` and `lib/shell/bridge/scripts.ts`): a re-created WebView starts at the held route; the 8 s ready timeout on a Console whose listener is not mounted falls back to `location.assign` of the route; a server switch starts at its route. A warm tap is held until `ready` and dispatched as the event, which the listener takes. **`@object-ui/app-shell`** exports `resolveNotificationTarget`, `type NotificationTarget` and `CONSOLE_ROOT_SEGMENTS` from the package root (they were on `utils/` only). The package is private, so nothing is published. `ui/packages/mobile` is unchanged. ## Why From day one of push (#29041), every notification whose link is app-relative (the synthesized `/OBJECT/ID` shape) would open Home instead of its record, which is the defect objectui#5179 fixed for the inbox doors. ## Tests - `App.nativeShellMode-29039.test.tsx` (real `App` under the real `BrowserRouter`, fake bridge), two new top-level `describe` blocks. Listener: hosted under the URL's app with query and hash kept, event cancelled; `/apps/x/y/1` passes through; with no app open, the route is handed on and the catch-all hosts it under the default app; away from an app, it is hosted under the app last open, by its route segment (`_packageId`). Catch-all: first active app wins over an inactive one; last-open app re-checked; waits while `loading`; waits while a session-cached list is still re-read (status `loading`, measured live); a failed list keeps `/`; controls `/dev/nope`, `/home/nope`, `/apps` keep `/`; a real `/home` is rendered by its own route and never rewritten. - New `nativeShellRoute-29750.test.ts` (8 cases): the listener's pure answer, including the unreachable `null` / `external` branches, and the unmatched-path predicate's table. - `App.agentChatLink-10640.test.tsx`: its `RootLandingRedirect` mock now spreads `importOriginal` (one line, no assertion change), because its control URL reaches the catch-all, which reads `isAppListConclusive` from that module. - Ablations (each with `scripts/ablation-replace.mjs`, mutation landed by anchor count and blob, restored to the HEAD blob with `git diff HEAD` empty): the wrapper navigating verbatim reds "an app-relative route is hosted under the app in the URL"; the catch-all always redirecting to `/` reds the six hosting and waiting cases, controls stay green. ## Acceptance notes - **Behaviour change on the web (scope ruled by the seat):** an unknown deep link whose first segment names no Console route (`/foo/bar`) now opens `/apps/APP/foo/bar` instead of Home. Measured live on app-crm: the app shows its own "Page not found. The URL you followed does not match any view in this app. Go back", inside the app's chrome, no loop, no page error. - **Deviation from the ruled leg 1, reported to the seat before the PR:** the listener's host hint is the URL's `:appName` only, not `currentAppName ?? :appName`. `currentAppName` is an app name; it reaches the catch-all instead, which re-checks it against the app list exactly as the inbox does. - **Observation, not filed (carrier: none):** an `/apps/NAME/...` URL that addresses an app by its name rather than its `_packageId` segment lands correctly but re-addresses the page several times on the way (live history: alternating `crm_app` and `com.example.crm` replaces). Nothing here produces that form any more. - Cold-start reading is from the public mobile repository's `main` (`ddcf2d5`); nothing in that repository changes. ## Verification Final head `b1ed0110` (main merged twice; neither merge touched this PR's files). On that head: closure rebuild (70 tasks), `@object-ui/console` and `@object-ui/app-shell` `type-check` Done, and the 13 console test files that mock the root landing or use `@object-ui/mobile` plus the new unit file: 13 files, 62 tests, all passed. `eslint` on every touched file: exit 0. <details><summary>Derived gate sweep (dispatch-gates, 35 families, run at 93b08ba, reconciled)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands; every one exited 0, including `node scripts/check-sdui-manifest.mjs`, `pnpm --dir ui exec vitest run scripts/`, `pnpm check:type-check-debt`, `pnpm check:test-source-alias`, `pnpm check:cross-package-test-inputs` and the changeset gates. `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED. The changeset and manifest gates were re-run on `b1ed0110` before the PR opened. </details> <details><summary>Live run (app-crm --fresh plus the Vite Console on this branch, Playwright, fake bridge)</summary> - `/apps/com.example.crm/foo/bar`: the app's "Page not found" state. - First URL `/foo/bar`: `/apps/com.example.crm/foo/bar`. First URL `/crm_opportunity/ID`: `/apps/com.example.crm/crm_opportunity/record/ID`, the record. `/dev/nope`: Home, as before. - Push tap `/crm_opportunity/ID` from a fresh Home, from inside the app, and from Home after visiting the app: all land on `/apps/com.example.crm/crm_opportunity/record/ID`, event cancelled. Inside the app the history is one push plus the app's own record replace. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on the touched files; CI runs the full lint). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset (`.changeset/29750-native-push-route-hosted.md`). - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the maintainer's Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVL7fzYGjxa3ani6k8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ac50e4 commit b0ef25f

8 files changed

Lines changed: 451 additions & 10 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console): a push route from the mobile shell opens the record, hosted under an app the way the inbox hosts it
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
What changes:
14+
15+
- 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.
16+
- 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.
17+
- 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.
18+
- `@object-ui/app-shell` exports `resolveNotificationTarget`, its `NotificationTarget` type and `CONSOLE_ROOT_SEGMENTS` from the package root.
19+
20+
What to do: nothing.

‎ui/apps/console/src/App.tsx‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
* with extra `<Route>` children.
1313
*/
1414

15-
import { lazy, Suspense, useEffect, useMemo } from 'react';
16-
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
15+
import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react';
16+
import { BrowserRouter, Routes, Route, Navigate, Outlet, matchPath, useLocation, useNavigate } from 'react-router-dom';
1717
import { useNativeHostRouting } from '@object-ui/mobile';
1818
import { AuthProvider, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1919
import { UploadProvider, createObjectStackUploadAdapter } from '@object-ui/providers';
@@ -37,7 +37,7 @@ import {
3737
DefaultInvitationsPage,
3838
DefaultSettingsPage,
3939
DefaultAcceptInvitationPage,
40-
RedirectWithSplash,
40+
useMetadata,
4141
} from '@object-ui/app-shell';
4242

4343
import { AppContent, ConsolePermissionsRequest, ConsolePermissionsScope } from './AppContent';
@@ -47,6 +47,8 @@ import { RootLandingRedirect } from './components/RootLandingRedirect';
4747
import { ProtectedRoute } from './components/ProtectedRoute';
4848
import { studioRoutes } from './components/StudioRoute';
4949
import { SetupRoute } from './components/SetupRoute';
50+
import { UnmatchedRoute } from './components/UnmatchedRoute';
51+
import { nativeShellRouteTarget } from './nativeShellRoute';
5052
import { FormPage } from './components/FormPage';
5153
import { InternalFormRoute } from './components/InternalFormRoute';
5254
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
@@ -148,13 +150,33 @@ const BASENAME = resolveBasename();
148150
* the page. `data-os-native-shell` on the root element names the platform, so
149151
* a stylesheet can tell shell mode apart. Without the bridge this renders
150152
* nothing and installs nothing.
153+
*
154+
* The shell's route is a push route, the notification's `actionUrl`, and is
155+
* never navigated verbatim (objectstack#29750): it is hosted the way the inbox
156+
* hosts that link (`nativeShellRouteTarget`). Inside an app it goes under the
157+
* app the URL names. Anywhere else (Home, a cold start) the route goes to the
158+
* router as given, and the catch-all's `UnmatchedRoute` hosts it once the app
159+
* list is loaded: under the app the person last had open while it is still
160+
* active, else the first active app. The shell's published `currentAppName` is
161+
* left to that guard because it is an app NAME, and only the app list turns a
162+
* name into the address its route uses.
151163
*/
152164
function NativeShellRouting() {
153165
const location = useLocation();
154166
const navigate = useNavigate();
167+
// Outside every `MetadataProvider`, so the empty list; see `nativeShellRouteTarget`.
168+
const { apps } = useMetadata();
169+
const hostHint = matchPath('/apps/:appName/*', location.pathname)?.params.appName;
170+
const navigateShellRoute = useCallback(
171+
(route: string) => {
172+
const path = nativeShellRouteTarget(route, hostHint, apps);
173+
if (path !== null) navigate(path);
174+
},
175+
[apps, hostHint, navigate],
176+
);
155177
const host = useNativeHostRouting({
156178
route: `${location.pathname}${location.search}${location.hash}`,
157-
navigate,
179+
navigate: navigateShellRoute,
158180
});
159181
const platform = host?.platform;
160182
useEffect(() => {
@@ -486,7 +508,11 @@ export function App() {
486508
* `index` redirect above is deliberately NOT changed — it fires
487509
* under an already-painted organization layout, where covering the
488510
* screen with a splash would be the regression. */}
489-
<Route path="*" element={<RedirectWithSplash to="/" replace />} />
511+
{/* objectstack#29750 — an app-relative path (a push route such as
512+
* `/opportunity/123`, arriving as the first URL or handed over by
513+
* `NativeShellRouting`) is hosted under an app the way the inbox
514+
* hosts it; a stale Console link still redirects to `/`. */}
515+
<Route path="*" element={<UnmatchedRoute />} />
490516
</Routes>
491517
</ConsoleShell>
492518
</UploadProvider>

‎ui/apps/console/src/__tests__/App.agentChatLink-10640.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({
107107

108108
// ── Everything else App.tsx imports but this card does not touch ─────────
109109
vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough }));
110-
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
110+
// objectstack#29750: the catch-all reads `isAppListConclusive` from here.
111+
vi.mock('../components/RootLandingRedirect', async (importOriginal) => ({
112+
...(await importOriginal<Record<string, unknown>>()),
113+
RootLandingRedirect: stub('root-landing'),
114+
}));
111115
vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
112116
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
113117
vi.mock('../AppContent', () => ({

‎ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx‎

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,42 @@ import {
3333
} from '../../../../packages/mobile/src/nativeHost';
3434
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
3535

36-
const { passthrough, stub } = vi.hoisted(() => {
36+
const { passthrough, stub, shellState } = vi.hoisted(() => {
3737
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
3838
const stub = (testid: string) => () => <div data-testid={testid} />;
39-
return { passthrough, stub };
39+
// objectstack#29750: what the shell's navigation context and the metadata
40+
// provider answer. The defaults are what this file's other cases read
41+
// without either provider: no current app, an empty app list, not loading.
42+
const shellState = {
43+
currentAppName: undefined as string | undefined,
44+
apps: [] as Array<Record<string, unknown>>,
45+
loading: false,
46+
appStatus: undefined as 'idle' | 'loading' | 'ready' | 'error' | undefined,
47+
};
48+
return { passthrough, stub, shellState };
4049
});
4150

4251
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
4352
...(await importOriginal<Record<string, unknown>>()),
4453
ConsoleShell: passthrough,
4554
ConsoleToaster: () => null,
4655
LoadingScreen: stub('loading-screen'),
47-
RedirectWithSplash: stub('redirect-with-splash'),
56+
// Records where it would go, so the catch-all's answer is readable.
57+
RedirectWithSplash: ({ to }: { to: string }) => <div data-testid="redirect-with-splash" data-to={to} />,
58+
LoadingFallback: stub('loading-fallback'),
59+
useNavigationContext: () => ({
60+
context: 'app',
61+
setContext: () => {},
62+
currentAppName: shellState.currentAppName,
63+
setCurrentAppName: () => {},
64+
recordTitle: undefined,
65+
setRecordTitle: () => {},
66+
}),
67+
useMetadata: () => ({
68+
apps: shellState.apps,
69+
loading: shellState.loading,
70+
getTypeStatus: (type: string) => (type === 'app' ? shellState.appStatus : undefined),
71+
}),
4872
RequireAiSurface: passthrough,
4973
SystemRedirect: () => null,
5074
DefaultHomeLayout: passthrough,
@@ -76,7 +100,11 @@ vi.mock('../AppContent', () => ({
76100
ConsolePermissionsRequest: () => null,
77101
}));
78102
vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough }));
79-
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
103+
// objectstack#29750: the catch-all reads `isAppListConclusive` from here.
104+
vi.mock('../components/RootLandingRedirect', async (importOriginal) => ({
105+
...(await importOriginal<Record<string, unknown>>()),
106+
RootLandingRedirect: stub('root-landing'),
107+
}));
80108
vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
81109
vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') }));
82110
vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') }));
@@ -134,6 +162,10 @@ function dispatchNavigate(route: unknown): CustomEvent {
134162
beforeEach(() => {
135163
resetNativeHostEventsForTests();
136164
resetNativeHostWatchForTests();
165+
shellState.currentAppName = undefined;
166+
shellState.apps = [];
167+
shellState.loading = false;
168+
shellState.appStatus = undefined;
137169
window.history.pushState({}, '', '/login');
138170
});
139171

@@ -188,3 +220,141 @@ describe('control: the Console in a browser behaves as today', () => {
188220
expect(window.location.pathname).toBe('/login');
189221
});
190222
});
223+
224+
/**
225+
* objectstack#29750 — the shell's route is a push route, the notification's
226+
* `actionUrl`, and the push contract (`packages/spec/src/api/push-delivery.zod.ts`)
227+
* says it is resolved "as the inbox does, never verbatim": app-relative paths
228+
* are hosted under `/apps/APP/`, an `/apps/…` route passes through. Navigated
229+
* verbatim, `/opportunity/123` matched no route and the catch-all sent the
230+
* person to Home.
231+
*/
232+
describe('a push route from the shell is hosted the way the inbox hosts it', () => {
233+
it('an app-relative route is hosted under the app in the URL', async () => {
234+
installBridge();
235+
window.history.pushState({}, '', '/apps/crm/opportunity');
236+
render(<App />);
237+
await screen.findByTestId('app-content');
238+
const event = dispatchNavigate('/opportunity/123?tab=activity#notes');
239+
expect(event.defaultPrevented).toBe(true);
240+
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`)
241+
.toBe('/apps/crm/opportunity/123?tab=activity#notes');
242+
});
243+
244+
it('away from an app, the catch-all hosts it under the app the person last had open', async () => {
245+
// `currentAppName` is an app NAME; the catch-all turns it into the app's
246+
// route segment with the app list, which the listener does not have.
247+
installBridge();
248+
shellState.currentAppName = 'sales';
249+
shellState.apps = [{ name: 'crm', active: true }, { name: 'sales', active: true, _packageId: 'com.acme.sales' }];
250+
shellState.appStatus = 'ready';
251+
window.history.pushState({}, '', '/home');
252+
render(<App />);
253+
await screen.findByTestId('home-page');
254+
const event = dispatchNavigate('/opportunity/123');
255+
expect(event.defaultPrevented).toBe(true);
256+
expect(window.location.pathname).toBe('/opportunity/123');
257+
expect((await screen.findByTestId('redirect-with-splash')).getAttribute('data-to'))
258+
.toBe('/apps/com.acme.sales/opportunity/123');
259+
});
260+
261+
it('a Console route under /apps passes through unchanged', async () => {
262+
installBridge();
263+
window.history.pushState({}, '', '/apps/crm/opportunity');
264+
render(<App />);
265+
await screen.findByTestId('app-content');
266+
const event = dispatchNavigate('/apps/x/y/1');
267+
expect(event.defaultPrevented).toBe(true);
268+
expect(window.location.pathname).toBe('/apps/x/y/1');
269+
});
270+
271+
it('with no app open, the catch-all hosts the handed-over route under the default app', async () => {
272+
installBridge();
273+
shellState.apps = [{ name: 'crm', active: true }];
274+
shellState.appStatus = 'ready';
275+
render(<App />);
276+
await screen.findByTestId('login-page');
277+
const event = dispatchNavigate('/opportunity/123');
278+
expect(event.defaultPrevented).toBe(true);
279+
expect(window.location.pathname).toBe('/opportunity/123');
280+
expect((await screen.findByTestId('redirect-with-splash')).getAttribute('data-to'))
281+
.toBe('/apps/crm/opportunity/123');
282+
});
283+
});
284+
285+
/**
286+
* objectstack#29750 — the same push route can arrive as the page's FIRST URL:
287+
* the mobile shell starts a re-created WebView at a held route, and falls back
288+
* to `location.assign` of it when no navigate listener answered. The
289+
* catch-all hosts an app-relative path like the inbox; a stale Console link
290+
* keeps the old redirect to `/`.
291+
*/
292+
describe('the catch-all hosts an app-relative first URL under an app', () => {
293+
function catchAllTarget(): Promise<string | null> {
294+
return screen.findByTestId('redirect-with-splash').then((el) => el.getAttribute('data-to'));
295+
}
296+
297+
it('hosts it under the first active app, query and hash kept', async () => {
298+
shellState.apps = [{ name: 'gone', active: false }, { name: 'crm', active: true }];
299+
shellState.appStatus = 'ready';
300+
window.history.pushState({}, '', '/opportunity/123?tab=activity#notes');
301+
render(<App />);
302+
expect(await catchAllTarget()).toBe('/apps/crm/opportunity/123?tab=activity#notes');
303+
});
304+
305+
it('hosts it under the app the person last had open while that app is still active', async () => {
306+
shellState.apps = [{ name: 'crm', active: true }, { name: 'sales', active: true }];
307+
shellState.appStatus = 'ready';
308+
shellState.currentAppName = 'sales';
309+
window.history.pushState({}, '', '/opportunity/123');
310+
render(<App />);
311+
expect(await catchAllTarget()).toBe('/apps/sales/opportunity/123');
312+
});
313+
314+
it('waits for the app list before it answers', async () => {
315+
shellState.loading = true;
316+
window.history.pushState({}, '', '/opportunity/123');
317+
render(<App />);
318+
expect(await screen.findByTestId('loading-fallback')).toBeInTheDocument();
319+
expect(screen.queryByTestId('redirect-with-splash')).toBeNull();
320+
});
321+
322+
it('waits while a session-cached app list is still being re-read', async () => {
323+
// Measured live: the provider stops `loading` on the cached list while
324+
// the app type's own status still reads `loading`.
325+
shellState.apps = [{ name: 'crm', active: true }];
326+
shellState.appStatus = 'loading';
327+
window.history.pushState({}, '', '/opportunity/123');
328+
render(<App />);
329+
expect(await screen.findByTestId('loading-fallback')).toBeInTheDocument();
330+
expect(screen.queryByTestId('redirect-with-splash')).toBeNull();
331+
});
332+
333+
it('an app list that failed to load is no answer: the old redirect to / runs', async () => {
334+
shellState.appStatus = 'error';
335+
window.history.pushState({}, '', '/opportunity/123');
336+
render(<App />);
337+
expect(await catchAllTarget()).toBe('/');
338+
});
339+
340+
it('control: a stale Console link still redirects to /', async () => {
341+
shellState.apps = [{ name: 'crm', active: true }];
342+
shellState.appStatus = 'ready';
343+
for (const path of ['/dev/nope', '/home/nope', '/apps']) {
344+
window.history.pushState({}, '', path);
345+
render(<App />);
346+
expect(await catchAllTarget()).toBe('/');
347+
cleanup();
348+
}
349+
});
350+
351+
it('control: a real top-level Console route is never rewritten', async () => {
352+
shellState.apps = [{ name: 'crm', active: true }];
353+
shellState.appStatus = 'ready';
354+
window.history.pushState({}, '', '/home');
355+
render(<App />);
356+
expect(await screen.findByTestId('home-page')).toBeInTheDocument();
357+
expect(screen.queryByTestId('redirect-with-splash')).toBeNull();
358+
expect(window.location.pathname).toBe('/home');
359+
});
360+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#29750 — where a route the native shell hands the Console lands,
11+
* at the unit level. The wiring into the real router is pinned in
12+
* `App.nativeShellMode-29039.test.tsx`; this file holds the two answers that
13+
* no listener input can reach (the resolver's `null` and `external`), because
14+
* `isInAppRoute` refuses a blank route and any scheme or `//host` before the
15+
* Console ever sees it, and the unmatched-path predicate's table.
16+
*/
17+
18+
import { describe, expect, it } from 'vitest';
19+
import { isAppRelativeUnmatchedPath, nativeShellRouteTarget } from '../nativeShellRoute';
20+
21+
describe('nativeShellRouteTarget', () => {
22+
it('hosts an app-relative route under the hinted app', () => {
23+
expect(nativeShellRouteTarget('/opportunity/123', 'crm', [])).toBe('/apps/crm/opportunity/123');
24+
});
25+
26+
it('passes a Console route under /apps through', () => {
27+
expect(nativeShellRouteTarget('/apps/x/y/1', 'crm', [])).toBe('/apps/x/y/1');
28+
});
29+
30+
it('re-checks the hint against an app list in reach, as the inbox does', () => {
31+
const apps = [{ name: 'crm', active: true }, { name: 'old', active: false }];
32+
expect(nativeShellRouteTarget('/opportunity/123', 'old', apps)).toBe('/apps/crm/opportunity/123');
33+
});
34+
35+
it('with no hint, hands the route to the router unchanged', () => {
36+
expect(nativeShellRouteTarget('/opportunity/123', undefined, [])).toBe('/opportunity/123');
37+
});
38+
39+
it('a blank route resolves to nothing: no navigation', () => {
40+
expect(nativeShellRouteTarget('', 'crm', [])).toBeNull();
41+
expect(nativeShellRouteTarget(' ', 'crm', [])).toBeNull();
42+
});
43+
44+
it('an off-console route resolves to nothing for the router: no navigation', () => {
45+
expect(nativeShellRouteTarget('https://evil.example/x', 'crm', [])).toBeNull();
46+
expect(nativeShellRouteTarget('//evil.example/x', 'crm', [])).toBeNull();
47+
});
48+
});
49+
50+
describe('isAppRelativeUnmatchedPath', () => {
51+
it('a path whose first segment names no Console route is app-relative', () => {
52+
expect(isAppRelativeUnmatchedPath('/opportunity/123')).toBe(true);
53+
expect(isAppRelativeUnmatchedPath('/showcase_task')).toBe(true);
54+
});
55+
56+
it('a Console route served outside an app, the landing and /apps are not', () => {
57+
for (const path of ['/', '/home/x', '/dev/nope', '/studio/x/y', '/forms/x/y', '/apps', '/apps/']) {
58+
expect(isAppRelativeUnmatchedPath(path), path).toBe(false);
59+
}
60+
});
61+
});

0 commit comments

Comments
 (0)