Skip to content

Commit 61ba04f

Browse files
fix(app-shell): one app opens under one address — name-alias routes move onto the package id (#26262)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9FP9GUkjfjntrFmztxSoedK)_ Fixes #26183 Clause-②: no Metadata-protocol: none ## What was wrong ADR-0048 §3.6 makes the package id the canonical `/apps/SEGMENT` key ("Canonical = package id"), and objectui#11818 / #11887 moved the app-independent links onto it. The sidebar was already right: `UnifiedSidebar` builds `basePath` from `appRouteSegment(activeApp)`, and `resolveHref` in `@object-ui/layout` builds object (with or without `viewName`), component, page, dashboard, report and doc entries on that `basePath`. So the obligations entry and the approvals inbox in the card are on the canonical address. The second family came from the app NAME alias. `matchAppBySegment` resolves a name, `AppContent` then keyed every path it builds on the URL's own segment (`appSegment = appName ?? …`), and two landing producers emitted the name: `resolveDeclaredHomePath` (top-bar logo, Home) and the console's `resolveLandingPath` (the post-login `/`). Landing on `/apps/clm` kept list rows, record pages and related lists on `clm`, while every sidebar click went to `app.objectstack.hotclm`. ## What changed - `utils/appRoute.ts`: `appAddressSegment(apps, app)`, the one address of an app: its package id when that resolves back to the same app, else its name (a package that ships several apps shares one package id, which opens the first). `canonicalAppPathname(apps, segment, pathname)`, the alias route rewritten onto that address, or `null` when it already carries it. It cannot loop: its answer resolves back to the same app. - `console/AppContent.tsx`: a name-alias route is moved onto the address with `replace`, keeping the path, query string, hash and history state. This runs in an effect, so a layout already on screen is not unmounted. `appSegment` now keys on the matched app's address, so the recent-items tracker never records the alias. - `utils/homePath.ts` and the console's `RootLandingRedirect.tsx`: both landings name the address directly, so no redirect hop follows. They still agree, and `landingHomeParity-7256.test.ts` gains two cases for that. Old addresses still open. A name-based bookmark or shared link resolves through the alias as before and then lands on the package id. ## Tests - `AppContent.nameAliasRedirect-26183.test.tsx` (new, a real `AppContent` mount): the alias moves onto the package id with search, hash and state kept, as a `REPLACE`. Controls that do not move: the package-id route, an app whose name is its package id (no loop), and the second app of a shared package. - `appRoute.test.ts`: `appAddressSegment` and `canonicalAppPathname`, covering no loop, name equal to package id, package-less apps, a shared package, and segments outside `/apps`. - `homePath.test.ts`, `RootLandingRedirect.test.ts`, `landingHomeParity-7256.test.ts`: the landing names the package id. Two existing pins changed address because of this: declared Setup now lands on `/apps/com.objectstack.setup`, and Studio alone on `/apps/com.objectstack.studio`. What each pin checks (which app is the landing) is unchanged. - `AppContent.recentOnPackageRoute-11818.test.tsx`: its alias CONTROL now records the visit once, on the package-id address. Local run results are in the `os-dev-report` comment on the card. ## Acceptance notes - The many hardcoded `/apps/setup/…` links in the console still work, now with one replace-hop onto `/apps/com.objectstack.setup/…` for a session whose app list carries Setup. Moving those producers onto `resolveHostAppSegment` is not part of this PR (carrier: none). - The ADR-0048 §3.6 per-tenant friendly alias (namespace → package id) is still unbuilt. The name alias this PR rewrites is not that alias. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMwiEU2YGp3QigAjKszWFq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b029c90 commit 61ba04f

14 files changed

Lines changed: 461 additions & 31 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(app-shell,console): one app opens under one address — a name-alias route moves onto the package id
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An app with a package id answered under two address families in one session. ADR-0048 keys `/apps/<segment>` on the package id, and every sidebar entry builds on it, but the app's `name` still resolves as an alias, and a page opened on the alias built every link of its own (list rows, record pages, related lists) on the alias. Landing on `/apps/clm/…` therefore opened contracts under the name and obligations and the approvals inbox under `/apps/app.objectstack.hotclm/…`.
12+
13+
- **`@object-ui/app-shell`:** a route under `/apps/<name>/…` whose app carries a package id is rewritten, with `replace`, to the same path under `/apps/<package id>/…`, keeping the query string, hash and history state. Old name-based bookmarks and shared links keep opening, now on the one address. The top-bar logo and every Home affordance (`resolveDeclaredHomePath`) name the declared app by its package id. An app without a package id, an app whose name is its package id, and every app after the first of a package that ships several (the package id opens the first) keep their name as their address.
14+
- **`@object-ui/console`:** the `/` landing (`resolveLandingPath`) names the landing app by the same address, so post-login and the logo agree and no redirect hop follows.
15+
16+
What to do: nothing. Links that name an app by its name keep working; they now arrive on its package-id address.

‎ui/apps/console/src/components/RootLandingRedirect.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,25 @@ describe('resolveLandingPath', () => {
9797
// The declared landing is the escape hatch: an admin-console deployment
9898
// that genuinely wants Setup as its first screen says so with
9999
// `isDefault`, and rule 1 is deliberately untouched by this fix.
100-
expect(resolveLandingPath([{ ...SETUP, isDefault: true }])).toBe('/apps/setup');
100+
// On Setup's package id, its one address (objectstack-ai/objectstack#26183).
101+
expect(resolveLandingPath([{ ...SETUP, isDefault: true }])).toBe('/apps/com.objectstack.setup');
101102
});
102103

103104
it('CONTROL: a one-app PRODUCT deployment still lands in its app (#2027 rule 2 intact)', () => {
104105
expect(resolveLandingPath([{ name: 'crm' }])).toBe('/apps/crm');
105106
});
106107

108+
it('lands on the package id, never the name alias (objectstack-ai/objectstack#26183)', () => {
109+
// The name alias left the whole session on `/apps/<name>/…` while the
110+
// sidebar built every entry on the package id.
111+
expect(resolveLandingPath([{ name: 'clm', _packageId: 'app.objectstack.hotclm' }])).toBe(
112+
'/apps/app.objectstack.hotclm',
113+
);
114+
expect(
115+
resolveLandingPath([{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP]),
116+
).toBe('/apps/app.objectstack.hotclm');
117+
});
118+
107119
it('CONTROL: Setup alongside a product app is unchanged — this fix only reads the SINGLE-app case', () => {
108120
// Every real deployment ships Setup. Excluding it from the *count* would
109121
// silently re-route `[crm, setup]` from /home into /apps/crm — a far
@@ -122,7 +134,7 @@ describe('resolveLandingPath', () => {
122134
// not. Widening this to every platform app is a separate judgment call
123135
// that this card does not authorize.
124136
expect(resolveLandingPath([{ name: 'studio', _packageId: 'com.objectstack.studio' }])).toBe(
125-
'/apps/studio',
137+
'/apps/com.objectstack.studio',
126138
);
127139
});
128140
});

‎ui/apps/console/src/components/RootLandingRedirect.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ function isPlatformSetupApp(app: LandingApp): boolean {
6565
return app._packageId === SETUP_APP_PACKAGE_ID || app.name === SETUP_APP_NAME;
6666
}
6767

68+
/**
69+
* The ONE segment that addresses `app` among `apps` (ADR-0048 option A): its
70+
* package id whenever that resolves back to this very app, else its name — a
71+
* package shipping several apps leaves the name as the only address that
72+
* reaches the later ones. The same answer as app-shell's `appAddressSegment`,
73+
* spelled out here for the reason rule 1 below gives for not importing the
74+
* chrome's policy; `landingHomeParity-7256.test.ts` holds the two equal.
75+
*
76+
* Landing on the name used to leave the whole session on the name alias while
77+
* the sidebar built every link on the package id (objectstack-ai/objectstack#26183).
78+
*/
79+
function landingSegment(apps: readonly LandingApp[], app: LandingApp & { name: string }): string {
80+
const pkg = app._packageId;
81+
if (!pkg) return app.name;
82+
const resolved = apps.find((a) => a?._packageId === pkg) ?? apps.find((a) => a?.name === pkg);
83+
return resolved === app ? pkg : app.name;
84+
}
85+
6886
/**
6987
* The path `/` should redirect to, resolved purely from the App list. Extracted
7088
* from the component so the policy is unit-testable without a router/render.
@@ -84,7 +102,7 @@ export function resolveLandingPath(apps: readonly LandingApp[] | null | undefine
84102
// chrome-free hand-rolled mock, so importing the policy from there would
85103
// make the fixture, not the shipped function, the thing under test.
86104
const defaultApp = list.find((a) => a.isDefault === true);
87-
if (defaultApp) return `/apps/${defaultApp.name}`;
105+
if (defaultApp) return `/apps/${landingSegment(apps ?? [], defaultApp)}`;
88106

89107
// 2. A single-app deployment lands straight in that App (no one-tile
90108
// launcher) — but Setup alone is NOT a one-app deployment.
@@ -111,7 +129,7 @@ export function resolveLandingPath(apps: readonly LandingApp[] | null | undefine
111129
// deployment from `/home` into the product app — a far larger change than
112130
// this card asks for, and one nobody measured.
113131
const visible = list.filter((a) => a.active !== false && a.hidden !== true);
114-
if (visible.length === 1 && !isPlatformSetupApp(visible[0])) return `/apps/${visible[0].name}`;
132+
if (visible.length === 1 && !isPlatformSetupApp(visible[0])) return `/apps/${landingSegment(apps ?? [], visible[0])}`;
115133

116134
// 3. Multi-app default: the workspace launcher.
117135
return '/home';

‎ui/apps/console/src/components/landingHomeParity-7256.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const LISTS: ReadonlyArray<{ label: string; apps: any[] }> = [
4444
{ label: 'ordinary multi-app environment, nothing declared', apps: [{ name: 'crm' }, { name: 'ops' }, SETUP] },
4545
{ label: 'one product app + Setup, nothing declared', apps: [{ name: 'crm' }, SETUP] },
4646
{ label: 'Setup only — a fresh environment', apps: [SETUP] },
47+
{ label: 'declared packaged app (objectstack-ai/objectstack#26183)', apps: [{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP] },
48+
{ label: 'declared second app of a two-app package', apps: [{ name: 'crm', _packageId: 'com.acme.suite' }, { name: 'ops', _packageId: 'com.acme.suite', isDefault: true }, SETUP] },
4749
{ label: 'empty list', apps: [] },
4850
];
4951

@@ -78,6 +80,21 @@ describe('objectui#7256 — one declaration, one home', () => {
7880
expect(resolveLandingPath(apps)).toBe('/apps/alpha');
7981
});
8082

83+
it('DRIFT CANARY: both land on the package id, the address the sidebar builds (objectstack-ai/objectstack#26183)', () => {
84+
const apps = [{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP];
85+
expect(resolveDeclaredHomePath(apps)).toBe('/apps/app.objectstack.hotclm');
86+
expect(resolveLandingPath(apps)).toBe('/apps/app.objectstack.hotclm');
87+
});
88+
89+
it('DRIFT CANARY: a package id another app answers to is not this app\'s address', () => {
90+
const apps = [
91+
{ name: 'crm', _packageId: 'com.acme.suite' },
92+
{ name: 'ops', _packageId: 'com.acme.suite', isDefault: true },
93+
];
94+
expect(resolveDeclaredHomePath(apps)).toBe('/apps/ops');
95+
expect(resolveLandingPath(apps)).toBe('/apps/ops');
96+
});
97+
8198
it('DRIFT CANARY: a nameless declaration is a declaration to neither', () => {
8299
const apps = [{ isDefault: true }, { name: 'crm' }, SETUP];
83100
expect(resolveDeclaredHomePath(apps)).toBeNull();

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
3737
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
3838
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
3939
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
40-
import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js';
40+
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
4141
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
4242
import type { NavigationItem } from '@object-ui/types';
4343

@@ -376,11 +376,28 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
376376
: undefined);
377377

378378
// ADR-0048 (A) — the segment every `/apps/…` path this component builds is
379-
// keyed on: the URL's own (`appName` — the package id, or the name alias the
380-
// user followed), else the resolved app's route segment. Never `activeApp.name`
381-
// raw: on a package-id route that is a second address for the same app, and
382-
// the recent-items tracker below matches it against the URL (objectui#11818).
383-
const appSegment = appName ?? appRouteSegment(activeApp);
379+
// keyed on: the matched app's one address (`appAddressSegment` — its package
380+
// id), else the URL's own, else the resolved app's route segment. Never
381+
// `activeApp.name` raw: on a package-id route that is a second address for
382+
// the same app, and the recent-items tracker below matches it against the URL
383+
// (objectui#11818).
384+
const appSegment = appAddressSegment(apps, matchedApp) ?? appName ?? appRouteSegment(activeApp);
385+
386+
// objectstack-ai/objectstack#26183 — a URL that reached the app through its
387+
// NAME alias moves to the app's one address, path, search, hash and history
388+
// state kept. The alias still opens (old bookmarks and shared links keep
389+
// working), but a page left on it built every link on the alias while the
390+
// sidebar built on the package id: one app, two address families. `replace`,
391+
// so Back never returns to the alias. An effect rather than a `<Navigate>`
392+
// return, so a layout already on screen is not unmounted for the swap.
393+
const canonicalPathname = canonicalAppPathname(apps, appName, location.pathname);
394+
useEffect(() => {
395+
if (!canonicalPathname) return;
396+
navigate(`${canonicalPathname}${location.search}${location.hash}`, {
397+
replace: true,
398+
state: location.state,
399+
});
400+
}, [canonicalPathname, location.search, location.hash, location.state, navigate]);
384401

385402
// objectui#12809 — the lazy metadata buckets the CURRENT route reads at
386403
// render (`appRouteMetadataReads.ts` declares them per route). This used to

‎ui/packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import '@testing-library/jest-dom/vitest';
2929
import { describe, it, expect, vi, beforeEach } from 'vitest';
3030
import React from 'react';
31-
import { render, screen } from '@testing-library/react';
31+
import { render, screen, waitFor } from '@testing-library/react';
3232
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
3333

3434
// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded
@@ -160,8 +160,9 @@ describe('AppContent — an app with empty navigation lands on an empty state (o
160160

161161
const emptyState = await screen.findByTestId('app-no-access-empty-state');
162162
expect(emptyState).toHaveTextContent('Nothing in HotCLM is available to you yet');
163-
// The app root, not a redirect somewhere else.
164-
expect(screen.getByTestId('pathname').textContent).toBe('/apps/clm');
163+
// The app root, not a redirect somewhere else — on the app's package id,
164+
// its one address (objectstack-ai/objectstack#26183).
165+
await waitFor(() => expect(screen.getByTestId('pathname').textContent).toBe('/apps/app.objectstack.hotclm'));
165166
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'clm');
166167
});
167168

@@ -215,7 +216,9 @@ describe('AppContent — an app with empty navigation lands on an empty state (o
215216
renderConsoleAt('/apps/clm');
216217

217218
expect(await screen.findByTestId('object-view')).toBeInTheDocument();
218-
expect(screen.getByTestId('pathname').textContent).toBe('/apps/clm/contract');
219+
await waitFor(() =>
220+
expect(screen.getByTestId('pathname').textContent).toBe('/apps/app.objectstack.hotclm/contract'),
221+
);
219222
expect(screen.queryByTestId('app-no-access-empty-state')).not.toBeInTheDocument();
220223
});
221224

0 commit comments

Comments
 (0)