Skip to content

Commit e8ad3ba

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(app-shell): ADR-0048 (A) — package-id app routing (resolution layer) (#1693)
* feat(app-shell): ADR-0048 (A) package-id routing — foundation (resolver) First, non-breaking slice of the unified package-id routing refactor. - New `appRoute` helpers: `appRouteSegment(app)` (canonical link segment = package id, name fallback) and `matchAppBySegment(apps, seg)` (resolve a route segment to its app, preferring `_packageId`, falling back to `name`). - AppContent's active-app selection now uses `matchAppBySegment`, so `/apps/<packageId>` resolves — while `/apps/<appName>` keeps working (the name fallback doubles as a per-tenant alias / legacy URL). No emitted URL changes yet, so live behaviour is unchanged. Remaining (browser-verified follow-up): switch href emission from app objects to package id at the ~13 nav sites (AppSidebar/UnifiedSidebar/AppSwitcher/ AppHeader/CommandPalette/SearchResultsPage/UnpublishedAppBar/useNavigationSync/ ConsoleFloatingChatbot), and move docs to /apps/:packageId/docs/:name (DocPage prefer-local + DocsIndex package-scoped links). Test: utils/__tests__/appRoute.test.ts (7). Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-shell): ADR-0048 (A) — resolve active app by package id across nav (selection layer) Now that each platform app is its own package (framework #1813), package-id routing is unambiguous. Route the active-app lookup in every nav surface through `matchAppBySegment` (prefer `_packageId`, fall back to app name) so `/apps/<packageId>` resolves WITH sidebar + header intact — while `/apps/<name>` keeps working (the name fallback doubles as a per-tenant alias). Additive and non-breaking: no emitted URL changes. Sites: AppSidebar, UnifiedSidebar, AppSwitcher, AppHeader, SearchResultsPage, UnpublishedAppBar, useNavigationSync (AppContent done in the foundation commit). Browser-verified against a live app-showcase backend with the split: - /apps/com.objectstack.studio → Studio renders with full sidebar + header - /apps/studio (name) → still renders (fallback) Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 62d31bc commit e8ad3ba

11 files changed

Lines changed: 93 additions & 12 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState';
2525
import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
2626
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
2727
import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
28+
import { matchAppBySegment } from '../utils/appRoute';
2829
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
2930
import { ExpressionEvaluator } from '@object-ui/core';
3031

@@ -172,7 +173,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
172173
const activeApps = apps.filter((a: any) => a.active !== false);
173174
const launcherApps = activeApps.filter((a: any) => a.hidden !== true);
174175
const activeApp =
175-
apps.find((a: any) => a.name === appName) ||
176+
// ADR-0048 (A) — the route segment is the package id; resolve by it,
177+
// falling back to the app name (legacy/alias URL).
178+
matchAppBySegment(apps, appName) ||
176179
launcherApps.find((a: any) => a.isDefault === true) ||
177180
launcherApps[0];
178181

packages/app-shell/src/hooks/useNavigationSync.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { NavigationItem, AppSchema } from '@object-ui/types';
1515
import { useObjectTranslation } from '@object-ui/i18n';
1616
import { useAdapter } from '../providers/AdapterProvider';
1717
import { useMetadata } from '../providers/MetadataProvider';
18+
import { matchAppBySegment } from '../utils/appRoute';
1819
import { usePreviewDrafts } from '../preview/PreviewModeContext';
1920

2021
// ============================================================================
@@ -222,7 +223,7 @@ export function useNavigationSync(): UseNavigationSyncReturn {
222223
/** Find the current app schema from metadata by name. */
223224
const findApp = useCallback(
224225
(appName: string): AppSchema | undefined =>
225-
apps.find((a: any) => a.name === appName) as AppSchema | undefined,
226+
matchAppBySegment(apps, appName) as AppSchema | undefined,
226227
[apps],
227228
);
228229

packages/app-shell/src/layout/AppHeader.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
6161
import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
6262
import { useAuth, getUserInitials } from '@object-ui/auth';
6363
import { useMetadata } from '../providers/MetadataProvider';
64-
import { resolveI18nLabel, preferLocal } from '../utils';
64+
import { resolveI18nLabel, preferLocal, matchAppBySegment } from '../utils';
6565
import { getIcon } from '../utils/getIcon';
6666
import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
6767
import { useNavigationContext } from '../context/NavigationContext';
@@ -457,7 +457,8 @@ export function AppHeader({
457457

458458
// Filter objects to only those belonging to the current app via its navigation
459459
const appNameKey = activeAppName || currentAppName || appNameFromRoute;
460-
const currentApp = (metadataApps || []).find((a: any) => a.name === appNameKey);
460+
// ADR-0048 (A) — appNameKey may be a package id (route segment); match by it.
461+
const currentApp = matchAppBySegment(metadataApps || [], appNameKey);
461462
const appNavObjectNames = new Set<string>();
462463
const collectNavObjects = (items: any[]) => {
463464
for (const item of items || []) {

packages/app-shell/src/layout/AppSidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { usePermissions } from '@object-ui/permissions';
6161
import { useRecentItems } from '../hooks/useRecentItems';
6262
import { useFavorites } from '../hooks/useFavorites';
6363
import { useNavPins } from '../hooks/useNavPins';
64-
import { resolveI18nLabel } from '../utils';
64+
import { resolveI18nLabel, matchAppBySegment } from '../utils';
6565
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
6666
import { useAppContextSelectors } from './ContextSelectors';
6767

@@ -175,7 +175,8 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
175175
const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);
176176
// The active-app lookup still spans ALL apps (incl. hidden) so a
177177
// direct /apps/account navigation keeps rendering the Account branding.
178-
const activeApp = apps.find((a: any) => a.name === activeAppName && a.active !== false) || activeApps[0];
178+
// ADR-0048 (A) — route segment may be a package id; match by it (name fallback).
179+
const activeApp = matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0];
179180

180181
// Extract branding information from spec
181182
const logo = activeApp?.branding?.logo;

packages/app-shell/src/layout/AppSwitcher.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@object-ui/components';
1818
import { ChevronDown, Check } from 'lucide-react';
1919
import { useMetadata } from '../providers/MetadataProvider';
20-
import { resolveI18nLabel } from '../utils';
20+
import { resolveI18nLabel, matchAppBySegment } from '../utils';
2121
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
2222
import { getIcon } from '../utils/getIcon';
2323

@@ -36,7 +36,8 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) {
3636
// (personal-settings-style surfaces like the Account app), not the
3737
// top-level App Switcher.
3838
const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);
39-
const activeApp = activeApps.find((a: any) => a.name === activeAppName) || apps.find((a: any) => a.name === activeAppName && a.active !== false) || activeApps[0];
39+
// ADR-0048 (A) — route segment may be a package id; match by it (name fallback).
40+
const activeApp = matchAppBySegment(activeApps, activeAppName) || matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0];
4041

4142
if (!activeApp) return null;
4243

packages/app-shell/src/layout/UnifiedSidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
4747
import { useRecentItems } from '../hooks/useRecentItems';
4848
import { useFavorites } from '../hooks/useFavorites';
4949
import { useNavPins } from '../hooks/useNavPins';
50-
import { resolveI18nLabel } from '../utils';
50+
import { resolveI18nLabel, matchAppBySegment } from '../utils';
5151
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
5252
// useObjectLabel provides appLabel/appDescription for convention-based
5353
// i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label
@@ -183,7 +183,8 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
183183
// Filter switcher to non-hidden apps; active-app lookup spans all so
184184
// direct navigation to /apps/account still renders.
185185
const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);
186-
const activeApp = apps.find((a: any) => a.name === (activeAppName || currentAppName) && a.active !== false) || activeApps[0];
186+
// ADR-0048 (A) — route segment may be a package id; match by it (name fallback).
187+
const activeApp = matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName || currentAppName) || activeApps[0];
187188

188189
// Drag-reorder and pin persistence
189190
const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home');

packages/app-shell/src/preview/UnpublishedAppBar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { toast } from 'sonner';
2626
import { Button } from '@object-ui/components';
2727
import { useObjectTranslation } from '@object-ui/i18n';
2828
import { useMetadata } from '../providers/MetadataProvider';
29+
import { matchAppBySegment } from '../utils/appRoute';
2930
import { usePreviewDrafts } from './PreviewModeContext';
3031

3132
export function UnpublishedAppBar() {
@@ -40,7 +41,7 @@ export function UnpublishedAppBar() {
4041
if (preview) return null;
4142
const routeApp = appName ?? location.pathname.match(/\/apps\/([^/?#]+)/)?.[1];
4243
if (!routeApp) return null;
43-
const app = (apps ?? []).find((a: any) => a?.name === routeApp);
44+
const app = matchAppBySegment(apps ?? [], routeApp);
4445
if (!app || (app as any).hidden !== true) return null;
4546

4647
const publish = async () => {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { appRouteSegment, matchAppBySegment } from '../appRoute';
3+
4+
/**
5+
* ADR-0048 (option A) — package-id route segment.
6+
*/
7+
describe('appRouteSegment', () => {
8+
it('returns the package id when present', () => {
9+
expect(appRouteSegment({ name: 'crm', _packageId: 'com.acme.crm' })).toBe('com.acme.crm');
10+
});
11+
it('falls back to the app name when no package id', () => {
12+
expect(appRouteSegment({ name: 'crm' })).toBe('crm');
13+
});
14+
it('returns undefined for nullish input', () => {
15+
expect(appRouteSegment(undefined)).toBeUndefined();
16+
expect(appRouteSegment(null)).toBeUndefined();
17+
});
18+
});
19+
20+
describe('matchAppBySegment', () => {
21+
const crm = { name: 'crm', _packageId: 'com.acme.crm' };
22+
const hr = { name: 'crm', _packageId: 'com.beta.crm' }; // same display name, different vendor
23+
const apps = [crm, hr];
24+
25+
it('matches by package id (disambiguates same-named apps)', () => {
26+
expect(matchAppBySegment(apps, 'com.acme.crm')).toBe(crm);
27+
expect(matchAppBySegment(apps, 'com.beta.crm')).toBe(hr);
28+
});
29+
it('falls back to matching by name (legacy/alias URL)', () => {
30+
expect(matchAppBySegment([{ name: 'sales' }], 'sales')).toEqual({ name: 'sales' });
31+
});
32+
it('prefers a package-id match over a name match', () => {
33+
const byName = { name: 'com.acme.crm' }; // pathological: an app literally named like a pkg id
34+
expect(matchAppBySegment([byName, crm], 'com.acme.crm')).toBe(crm);
35+
});
36+
it('returns undefined for missing inputs', () => {
37+
expect(matchAppBySegment(apps, undefined)).toBeUndefined();
38+
expect(matchAppBySegment(null, 'com.acme.crm')).toBeUndefined();
39+
});
40+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* ADR-0048 (option A) — the `/apps/<segment>` route is keyed on the **package
3+
* id** (reverse-domain, globally unique), not the app's display name. This makes
4+
* two vendors' same-named apps unambiguous and self-describing in the URL.
5+
*
6+
* - `appRouteSegment(app)` — the canonical route segment for linking TO an app
7+
* (its package id; falls back to `name` for runtime/DB apps with no
8+
* `_packageId`).
9+
* - `matchAppBySegment(apps, seg)` — resolve a route segment back to its app,
10+
* preferring the package id and falling back to the app name. The name
11+
* fallback doubles as a per-tenant friendly alias and keeps legacy
12+
* name-based URLs/bookmarks working. Callers keep their own
13+
* default/first-app fallback around this match.
14+
*/
15+
16+
type AppLike = { name?: unknown; _packageId?: unknown } & Record<string, unknown>;
17+
18+
export function appRouteSegment(app: AppLike | null | undefined): string | undefined {
19+
if (!app) return undefined;
20+
const seg = (app._packageId as string | undefined) ?? (app.name as string | undefined);
21+
return seg ?? undefined;
22+
}
23+
24+
export function matchAppBySegment<T extends AppLike>(
25+
apps: readonly T[] | null | undefined,
26+
seg: string | null | undefined,
27+
): T | undefined {
28+
if (!apps || seg == null) return undefined;
29+
return apps.find((a) => a?._packageId === seg) ?? apps.find((a) => a?.name === seg);
30+
}

packages/app-shell/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { deriveRelatedLists } from './deriveRelatedLists';
1414
export type { DerivedRelatedList } from './deriveRelatedLists';
1515

1616
export { preferLocal } from './preferLocal';
17+
export { appRouteSegment, matchAppBySegment } from './appRoute';
1718

1819
/**
1920
* Resolves an I18nLabel to a plain string.

0 commit comments

Comments
 (0)