Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/26183-one-app-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(app-shell,console): one app opens under one address — a name-alias route moves onto the package id

Clause-②: no
Metadata-protocol: none

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/…`.

- **`@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.
- **`@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.

What to do: nothing. Links that name an app by its name keep working; they now arrive on its package-id address.
16 changes: 14 additions & 2 deletions ui/apps/console/src/components/RootLandingRedirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,25 @@ describe('resolveLandingPath', () => {
// The declared landing is the escape hatch: an admin-console deployment
// that genuinely wants Setup as its first screen says so with
// `isDefault`, and rule 1 is deliberately untouched by this fix.
expect(resolveLandingPath([{ ...SETUP, isDefault: true }])).toBe('/apps/setup');
// On Setup's package id, its one address (objectstack-ai/objectstack#26183).
expect(resolveLandingPath([{ ...SETUP, isDefault: true }])).toBe('/apps/com.objectstack.setup');
});

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

it('lands on the package id, never the name alias (objectstack-ai/objectstack#26183)', () => {
// The name alias left the whole session on `/apps/<name>/…` while the
// sidebar built every entry on the package id.
expect(resolveLandingPath([{ name: 'clm', _packageId: 'app.objectstack.hotclm' }])).toBe(
'/apps/app.objectstack.hotclm',
);
expect(
resolveLandingPath([{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP]),
).toBe('/apps/app.objectstack.hotclm');
});

it('CONTROL: Setup alongside a product app is unchanged — this fix only reads the SINGLE-app case', () => {
// Every real deployment ships Setup. Excluding it from the *count* would
// silently re-route `[crm, setup]` from /home into /apps/crm — a far
Expand All @@ -122,7 +134,7 @@ describe('resolveLandingPath', () => {
// not. Widening this to every platform app is a separate judgment call
// that this card does not authorize.
expect(resolveLandingPath([{ name: 'studio', _packageId: 'com.objectstack.studio' }])).toBe(
'/apps/studio',
'/apps/com.objectstack.studio',
);
});
});
Expand Down
22 changes: 20 additions & 2 deletions ui/apps/console/src/components/RootLandingRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ function isPlatformSetupApp(app: LandingApp): boolean {
return app._packageId === SETUP_APP_PACKAGE_ID || app.name === SETUP_APP_NAME;
}

/**
* The ONE segment that addresses `app` among `apps` (ADR-0048 option A): its
* package id whenever that resolves back to this very app, else its name — a
* package shipping several apps leaves the name as the only address that
* reaches the later ones. The same answer as app-shell's `appAddressSegment`,
* spelled out here for the reason rule 1 below gives for not importing the
* chrome's policy; `landingHomeParity-7256.test.ts` holds the two equal.
*
* Landing on the name used to leave the whole session on the name alias while
* the sidebar built every link on the package id (objectstack-ai/objectstack#26183).
*/
function landingSegment(apps: readonly LandingApp[], app: LandingApp & { name: string }): string {
const pkg = app._packageId;
if (!pkg) return app.name;
const resolved = apps.find((a) => a?._packageId === pkg) ?? apps.find((a) => a?.name === pkg);
return resolved === app ? pkg : app.name;
}

/**
* The path `/` should redirect to, resolved purely from the App list. Extracted
* from the component so the policy is unit-testable without a router/render.
Expand All @@ -84,7 +102,7 @@ export function resolveLandingPath(apps: readonly LandingApp[] | null | undefine
// chrome-free hand-rolled mock, so importing the policy from there would
// make the fixture, not the shipped function, the thing under test.
const defaultApp = list.find((a) => a.isDefault === true);
if (defaultApp) return `/apps/${defaultApp.name}`;
if (defaultApp) return `/apps/${landingSegment(apps ?? [], defaultApp)}`;

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

// 3. Multi-app default: the workspace launcher.
return '/home';
Expand Down
17 changes: 17 additions & 0 deletions ui/apps/console/src/components/landingHomeParity-7256.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const LISTS: ReadonlyArray<{ label: string; apps: any[] }> = [
{ label: 'ordinary multi-app environment, nothing declared', apps: [{ name: 'crm' }, { name: 'ops' }, SETUP] },
{ label: 'one product app + Setup, nothing declared', apps: [{ name: 'crm' }, SETUP] },
{ label: 'Setup only — a fresh environment', apps: [SETUP] },
{ label: 'declared packaged app (objectstack-ai/objectstack#26183)', apps: [{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP] },
{ 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] },
{ label: 'empty list', apps: [] },
];

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

it('DRIFT CANARY: both land on the package id, the address the sidebar builds (objectstack-ai/objectstack#26183)', () => {
const apps = [{ name: 'clm', _packageId: 'app.objectstack.hotclm', isDefault: true }, SETUP];
expect(resolveDeclaredHomePath(apps)).toBe('/apps/app.objectstack.hotclm');
expect(resolveLandingPath(apps)).toBe('/apps/app.objectstack.hotclm');
});

it('DRIFT CANARY: a package id another app answers to is not this app\'s address', () => {
const apps = [
{ name: 'crm', _packageId: 'com.acme.suite' },
{ name: 'ops', _packageId: 'com.acme.suite', isDefault: true },
];
expect(resolveDeclaredHomePath(apps)).toBe('/apps/ops');
expect(resolveLandingPath(apps)).toBe('/apps/ops');
});

it('DRIFT CANARY: a nameless declaration is a declaration to neither', () => {
const apps = [{ isDefault: true }, { name: 'crm' }, SETUP];
expect(resolveDeclaredHomePath(apps)).toBeNull();
Expand Down
29 changes: 23 additions & 6 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js';
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';

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

// ADR-0048 (A) — the segment every `/apps/…` path this component builds is
// keyed on: the URL's own (`appName` — the package id, or the name alias the
// user followed), else the resolved app's route segment. Never `activeApp.name`
// raw: on a package-id route that is a second address for the same app, and
// the recent-items tracker below matches it against the URL (objectui#11818).
const appSegment = appName ?? appRouteSegment(activeApp);
// keyed on: the matched app's one address (`appAddressSegment` — its package
// id), else the URL's own, else the resolved app's route segment. Never
// `activeApp.name` raw: on a package-id route that is a second address for
// the same app, and the recent-items tracker below matches it against the URL
// (objectui#11818).
const appSegment = appAddressSegment(apps, matchedApp) ?? appName ?? appRouteSegment(activeApp);

// objectstack-ai/objectstack#26183 — a URL that reached the app through its
// NAME alias moves to the app's one address, path, search, hash and history
// state kept. The alias still opens (old bookmarks and shared links keep
// working), but a page left on it built every link on the alias while the
// sidebar built on the package id: one app, two address families. `replace`,
// so Back never returns to the alias. An effect rather than a `<Navigate>`
// return, so a layout already on screen is not unmounted for the swap.
const canonicalPathname = canonicalAppPathname(apps, appName, location.pathname);
useEffect(() => {
if (!canonicalPathname) return;
navigate(`${canonicalPathname}${location.search}${location.hash}`, {
replace: true,
state: location.state,
});
}, [canonicalPathname, location.search, location.hash, location.state, navigate]);

// objectui#12809 — the lazy metadata buckets the CURRENT route reads at
// render (`appRouteMetadataReads.ts` declares them per route). This used to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';

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

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

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

expect(await screen.findByTestId('object-view')).toBeInTheDocument();
expect(screen.getByTestId('pathname').textContent).toBe('/apps/clm/contract');
await waitFor(() =>
expect(screen.getByTestId('pathname').textContent).toBe('/apps/app.objectstack.hotclm/contract'),
);
expect(screen.queryByTestId('app-no-access-empty-state')).not.toBeInTheDocument();
});

Expand Down
Loading
Loading