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
14 changes: 14 additions & 0 deletions .changeset/host-app-resolver-root-export-4280.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

`resolveHostAppSegment` is published from `@object-ui/app-shell`'s package root, and the console's local copy of it is deleted

Which app should host a framework-owned, app-INDEPENDENT page — the Approvals Inbox, the full inbox, an internal form's created record — is one hard-won definition, and `resolveHostAppSegment`'s docblock says so at length: prefer the app the user is in or last had open RE-CHECKED against the live active list, else their first active app, else the two last resorts. It lived in `app-shell/src/utils/appRoute.ts`, and `packages/app-shell` published only its package root, which re-exported `./utils` nowhere. So the definition was unreachable from every consumer outside the package.

Something outside the package needed it anyway. objectui#4109 had to name a host app for the record an internal `/forms/:name` submit creates, could not import the resolver, and shipped a documented local subset instead: steps 1 and 2 only, returning `null` where upstream falls through further. That is the "two readers of one prose contract" shape this repo keeps paying for (#3367 / #3842) — the next edit to the resolution order lands on one copy — and the divergence was disclosed rather than smuggled precisely so it could be collected later. This is that collection.

`resolveHostAppSegment` is now exported from the package root, alongside the two predicates it is defined in terms of (`appRouteSegment`, `filterActiveApps`), and the console's copy is gone: `createdRecordPath.ts` holds the URL shape and delegates the choice. The app record type it accepts is derived from the resolver's own signature rather than re-declared, so the call site cannot drift from it either.

**Behaviour change on the created-record redirect.** Converging on the full resolver means the two cases the subset answered `null` for now name an app. An empty openable list with a preferred app keeps that preferred app unchecked — an empty list means "not loaded yet" at least as often as "this user has no apps", and demoting someone demonstrably rendering inside `/apps/{preferred}/…` would reintroduce the defect objectui#4074 removed. Anything else unresolvable — no apps and nothing preferred, or apps carrying neither `_packageId` nor `name` — lands on `setup`, the least-surprising last resort rather than a broken link. Concretely: an internal form submit that previously stopped on FormPage's in-place confirmation ("no record page to land on") now navigates to the record under the resolved app, which is the same answer every other record link in the console already gives. The write itself was never at stake, only where the user is put afterwards. The remaining `null` from `buildCreatedRecordPath` means what it always should have: there is no record to point at, because the caller has no object or no id.
134 changes: 96 additions & 38 deletions apps/console/src/components/createdRecordPath.test.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,74 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Where an internal form submit lands (objectui#4109) — the host-app policy,
* unit-tested without a router or a metadata catalog.
* Where an internal form submit lands (objectui#4109) — the host-app policy at
* THIS call site, unit-tested without a router or a metadata catalog.
*
* objectui#4280 deleted the console's local copy of the resolution order, so
* these cases pin `@object-ui/app-shell`'s `resolveHostAppSegment` as composed
* by `buildCreatedRecordPath` — including the two last resorts the deleted
* subset used to answer `null` for.
*/

import { describe, expect, it } from 'vitest';
import {
buildCreatedRecordPath,
resolveRecordHostAppSegment,
type HostAppLike,
} from './createdRecordPath';
import { resolveHostAppSegment } from '@object-ui/app-shell';
import { buildCreatedRecordPath, type HostAppLike } from './createdRecordPath';

const SHOWCASE: HostAppLike = { name: 'showcase_app', _packageId: 'ai.objectstack.showcase' };
const CRM: HostAppLike = { name: 'crm_app', _packageId: 'ai.objectstack.crm' };

describe('resolveRecordHostAppSegment', () => {
/**
* The package-root export itself (objectui#4280). That barrel line in
* `packages/app-shell/src/index.ts` is the entire reason this module can hold
* no resolver of its own, and it is reachable from `apps/console` nowhere else
* — so without a pin it can be dropped in a tidy-up, and the only symptom would
* be the console growing a second copy of the resolution order back.
*
* Imported from the PACKAGE ROOT and never from `.../utils/appRoute`: the deep
* path resolves either way and would hold this test green through exactly the
* regression it exists to catch.
*/
describe('@object-ui/app-shell package root', () => {
it('publishes resolveHostAppSegment', () => {
expect(typeof resolveHostAppSegment).toBe('function');
});

it('publishes the FULL resolver, `setup` last resort included', () => {
// The behaviour that separates the shared resolver from the subset this
// module used to carry — that one answered `null` here.
expect(resolveHostAppSegment([], undefined)).toBe('setup');
});
});

describe('buildCreatedRecordPath — host app', () => {
it('prefers the app the user is in, matched by package id', () => {
expect(resolveRecordHostAppSegment([CRM, SHOWCASE], 'ai.objectstack.showcase')).toBe(
'ai.objectstack.showcase',
expect(buildCreatedRecordPath([CRM, SHOWCASE], 'ai.objectstack.showcase', 'obj', '1')).toBe(
'/apps/ai.objectstack.showcase/obj/record/1',
);
});

it('matches the preferred app by name too (runtime/DB apps, legacy URLs)', () => {
expect(resolveRecordHostAppSegment([CRM, SHOWCASE], 'showcase_app')).toBe(
'ai.objectstack.showcase',
expect(buildCreatedRecordPath([CRM, SHOWCASE], 'showcase_app', 'obj', '1')).toBe(
'/apps/ai.objectstack.showcase/obj/record/1',
);
});

it('returns the package id as the segment, never the name, when both exist (ADR-0048)', () => {
expect(resolveRecordHostAppSegment([SHOWCASE], 'showcase_app')).toBe('ai.objectstack.showcase');
it('uses the package id as the segment, never the name, when both exist (ADR-0048)', () => {
expect(buildCreatedRecordPath([SHOWCASE], 'showcase_app', 'obj', '1')).toBe(
'/apps/ai.objectstack.showcase/obj/record/1',
);
});

it('falls back to the name for a runtime app carrying no package id', () => {
expect(resolveRecordHostAppSegment([{ name: 'db_app' }], 'db_app')).toBe('db_app');
expect(buildCreatedRecordPath([{ name: 'db_app' }], 'db_app', 'obj', '1')).toBe(
'/apps/db_app/obj/record/1',
);
});

it('falls back to the first openable app when nothing is preferred', () => {
expect(resolveRecordHostAppSegment([CRM, SHOWCASE], undefined)).toBe('ai.objectstack.crm');
expect(buildCreatedRecordPath([CRM, SHOWCASE], undefined, 'obj', '1')).toBe(
'/apps/ai.objectstack.crm/obj/record/1',
);
});

/**
Expand All @@ -46,34 +77,61 @@ describe('resolveRecordHostAppSegment', () => {
*/
it('does not honour a preferred app that is no longer openable', () => {
const apps = [{ ...SHOWCASE, active: false }, CRM];
expect(resolveRecordHostAppSegment(apps, 'ai.objectstack.showcase')).toBe('ai.objectstack.crm');
expect(buildCreatedRecordPath(apps, 'ai.objectstack.showcase', 'obj', '1')).toBe(
'/apps/ai.objectstack.crm/obj/record/1',
);
});

it('skips hidden apps when falling back', () => {
const apps = [{ ...SHOWCASE, hidden: true }, CRM];
expect(resolveRecordHostAppSegment(apps, undefined)).toBe('ai.objectstack.crm');
expect(buildCreatedRecordPath(apps, undefined, 'obj', '1')).toBe(
'/apps/ai.objectstack.crm/obj/record/1',
);
});
});

/**
* The deliberate divergence from app-shell's `resolveHostAppSegment`, which
* ends at `setup`. Here an unresolvable app means we cannot NAME the record's
* page, and the caller must confirm the submit instead of sending the user to
* a URL that resolves to nothing. See the module docblock.
*/
it('returns null when no app can be named, rather than guessing `setup`', () => {
expect(resolveRecordHostAppSegment([], 'showcase_app')).toBeNull();
expect(resolveRecordHostAppSegment(undefined, undefined)).toBeNull();
expect(resolveRecordHostAppSegment([{ name: 'x', active: false }], undefined)).toBeNull();
// An app with no addressable identity at all is not a segment either.
expect(resolveRecordHostAppSegment([{}], undefined)).toBeNull();
/**
* The semantic alignment objectui#4280 exists to make. Every case here answered
* `null` under the deleted local subset — which `FormPage` reads as "no record
* page to land on" and answers by confirming the submit in place instead of
* navigating. They now resolve to a host app, so the submit lands on the
* record like every other record link in the console.
*/
describe('buildCreatedRecordPath — last resorts that used to be null (#4280)', () => {
it('an EMPTY app list keeps the preferred app, unchecked', () => {
// Upstream step 3: an empty list means "not loaded yet" at least as often
// as "this user has no apps", and demoting someone demonstrably rendering
// inside /apps/{preferred}/… would reintroduce the defect #4074 removed.
expect(buildCreatedRecordPath([], 'showcase_app', 'obj', '1')).toBe(
'/apps/showcase_app/obj/record/1',
);
});

it('no apps and nothing preferred lands on `setup`', () => {
expect(buildCreatedRecordPath(undefined, undefined, 'obj', '1')).toBe(
'/apps/setup/obj/record/1',
);
});

it('an app list that filters down to nothing lands on `setup`', () => {
expect(buildCreatedRecordPath([{ name: 'x', active: false }], undefined, 'obj', '1')).toBe(
'/apps/setup/obj/record/1',
);
});

it('an app with no addressable identity at all lands on `setup`', () => {
// Openable (neither deactivated nor hidden) but carrying neither
// `_packageId` nor `name`, so there is nothing to build a segment from —
// upstream step 4, not step 3.
expect(buildCreatedRecordPath([{}], undefined, 'obj', '1')).toBe('/apps/setup/obj/record/1');
});
});

describe('buildCreatedRecordPath', () => {
describe('buildCreatedRecordPath — URL shape', () => {
it('builds the app-scoped record path (ADR-0048 segment + object + id)', () => {
expect(
buildCreatedRecordPath([SHOWCASE], 'showcase_app', 'showcase_task', 'task-1'),
).toBe('/apps/ai.objectstack.showcase/showcase_task/record/task-1');
expect(buildCreatedRecordPath([SHOWCASE], 'showcase_app', 'showcase_task', 'task-1')).toBe(
'/apps/ai.objectstack.showcase/showcase_task/record/task-1',
);
});

it('percent-encodes an id that carries URL-significant characters', () => {
Expand All @@ -82,12 +140,12 @@ describe('buildCreatedRecordPath', () => {
);
});

/**
* The only `null` this module still answers, and the only one it ever
* should: there is no record to point at. The host app is never a reason.
*/
it('returns null when there is no object or no id to land on', () => {
expect(buildCreatedRecordPath([SHOWCASE], 'showcase_app', 'showcase_task', null)).toBeNull();
expect(buildCreatedRecordPath([SHOWCASE], 'showcase_app', '', 'task-1')).toBeNull();
});

it('returns null when no app can host the record page', () => {
expect(buildCreatedRecordPath([], 'showcase_app', 'showcase_task', 'task-1')).toBeNull();
});
});
125 changes: 46 additions & 79 deletions apps/console/src/components/createdRecordPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,94 +18,62 @@
* `InterfaceListPage`, …) builds this same shape. But `/forms/:name` names no
* app, so one has to be chosen.
*
* ## Which app, and the honest limit of this copy
* ## Which app — app-shell's resolver, and nothing local (objectui#4280)
*
* app-shell answers exactly this question for framework-owned, app-independent
* pages in `resolveHostAppSegment` (`utils/appRoute.ts`): prefer the app the
* user is in or last had open, RE-CHECKED against the live active list; else
* their first active app; else fall back further. Its docblock is emphatic that
* the order is one hard-won definition, and duplicating it would be the "two
* readers of one prose contract" mistake this repo keeps paying for.
* `resolveHostAppSegment` answers exactly this question for framework-owned,
* app-independent pages, and its docblock (`app-shell/src/utils/appRoute.ts`)
* is the contract: prefer the app the user is in or last had open, RE-CHECKED
* against the live active list; else their first active app; else the two last
* resorts below. This module holds NO copy of that order — it builds the URL
* shape and delegates the choice.
*
* We would import it — but `@object-ui/app-shell` exports only its package
* root, and that root re-exports `./utils` nowhere, so the helper is not
* reachable from `apps/console` without widening app-shell's public surface.
* That edit was out of scope for #4109 (a parallel agent holds app-shell), so
* this implements the FIRST TWO steps only and then STOPS:
* ### What #4280 deleted, and the behaviour that changed with it
*
* 1. `preferred` — the app the user is in / last had open — re-checked
* against the live list, so an app deactivated or hidden since is not
* resurrected as a link;
* 2. else their first openable app — arbitrary but reachable, and on a
* business user's workspace that is a business app;
* 3. else `null` — NOT `setup`. That is the deliberate divergence: the
* upstream helper's last resorts exist so a framework page always renders
* SOMEWHERE, whereas an unresolvable app here means we cannot name the
* record's page at all, and the caller must confirm the submit instead of
* sending the user to a URL that resolves to nothing.
* #4109 could not import the resolver (`@object-ui/app-shell` published only
* its package root, and that root re-exported `./utils` nowhere), so it shipped
* a documented local subset: steps 1–2 only, returning `null` where upstream
* falls through further. #4280 published the resolver from the package root and
* deleted the subset, and the divergence went with it — deliberately, since a
* second reader of one prose contract is the failure this repo keeps paying for
* (#3367 / #3842). The two cases that used to answer `null` now answer:
*
* Replacing all of this with the upstream helper is a pure deletion once it is
* exported; the divergence at step 3 is the only judgement to re-make.
*/

/** The fields this resolver reads off an App metadata record. */
export interface HostAppLike {
name?: unknown;
/** ADR-0048 owning package — the canonical identity of a shipped app. */
_packageId?: unknown;
active?: unknown;
hidden?: unknown;
}

/**
* The `/apps/<segment>` route segment for an app: its package id, falling back
* to its name for runtime/DB apps that have none. Mirrors app-shell's
* `appRouteSegment` — same reason, same unavailability.
* - **an EMPTY openable list with a `preferred` app** → `preferred`,
* unchecked. An empty list means "not loaded yet" at least as often as it
* means "this user has no apps", and a caller rendering inside
* `/apps/{preferred}/…` is demonstrably in that app;
* - **anything else unresolvable** (no apps and nothing preferred, or apps
* carrying neither `_packageId` nor `name`) → `setup`, the least-surprising
* last resort rather than a broken link.
*
* The user-visible delta is on the created-record redirect: `FormPage` treats a
* `null` path as "cannot name the record's page" and confirms the submit in
* place instead of navigating (`setSubmitted(true)`). With the host app now
* always named, a submit that used to stop on that confirmation navigates to
* the record under the resolved app instead. The write itself was never at
* stake — only where the user is put afterwards — and this is the same answer
* every other record link in the console already gives, which is the point.
*/
function routeSegment(app: HostAppLike | undefined): string | null {
if (!app) return null;
const seg = (typeof app._packageId === 'string' && app._packageId) || null;
if (seg) return seg;
return typeof app.name === 'string' && app.name ? app.name : null;
}

/**
* The apps a user can actually open. `active === false` deactivates an app;
* `hidden === true` keeps it out of the launcher. Neither is a place to send
* someone. (Deliberately NOT filtering `_unpublished`: it is the ADR-0045
* publish gate and is enforced server-side — see app-shell's `filterActiveApps`
* docblock — so filtering it here would hide a builder's own app from the
* builder and buy nothing.)
*/
function openableApps(apps: readonly HostAppLike[] | null | undefined): HostAppLike[] {
if (!apps) return [];
return apps.filter((a) => a?.active !== false && a?.hidden !== true);
}
import { resolveHostAppSegment } from '@object-ui/app-shell';

/**
* Which app should host the created record's page, as a route segment, or
* `null` when none can be named. See the module docblock for the order.
* The app records this module hands to app-shell's resolver, DERIVED from that
* resolver's own signature — the package publishes the function, not its
* parameter type, and re-declaring the field set here (what `name`,
* `_packageId`, `active` and `hidden` mean) is precisely the duplication
* objectui#4280 removed. Deriving it means this call site cannot drift from the
* shape the resolver accepts.
*/
export function resolveRecordHostAppSegment(
apps: readonly HostAppLike[] | null | undefined,
preferred: string | null | undefined,
): string | null {
const openable = openableApps(apps);
if (preferred) {
const stillOpenable =
openable.find((a) => a._packageId === preferred) ??
openable.find((a) => a.name === preferred);
const seg = routeSegment(stillOpenable);
if (seg) return seg;
}
return routeSegment(openable[0]);
}
export type HostAppLike = NonNullable<Parameters<typeof resolveHostAppSegment>[0]>[number];

/**
* The console path of a freshly created record, or `null` when it cannot be
* built — no openable app to host it, or a caller with no object/id. A null
* return is a supported answer, not a failure: `FormPage` confirms the submit
* instead of navigating to a path that resolves to nothing.
* The console path of a freshly created record, or `null` when there is no
* record to point at — a caller with no object or no id. That null is a
* supported answer, not a failure: `FormPage` confirms the submit instead of
* navigating to a path that names nothing.
*
* The host app is never a reason to return null any more — see the module
* docblock — because `resolveHostAppSegment` always names one.
*
* `recordId` is percent-encoded (ids are opaque and may carry `/` or `#`);
* `objectName` and the app segment are metadata identifiers, encoded for the
Expand All @@ -118,7 +86,6 @@ export function buildCreatedRecordPath(
recordId: string | null | undefined,
): string | null {
if (!objectName || !recordId) return null;
const appSegment = resolveRecordHostAppSegment(apps, preferredAppName);
if (!appSegment) return null;
const appSegment = resolveHostAppSegment(apps, preferredAppName);
return `/apps/${encodeURIComponent(appSegment)}/${encodeURIComponent(objectName)}/record/${encodeURIComponent(recordId)}`;
}
Loading
Loading