Skip to content

Commit 0c52f1a

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 24d18fc commit 0c52f1a

4 files changed

Lines changed: 75 additions & 1 deletion

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

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)