Skip to content

Commit 0a4e74e

Browse files
perf(app-shell): a console-created app's root reads its required objects by name (#29614)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNKT22zb7bwiwcAVkZKTaj7)_ Part of #29552 Clause-②: no Metadata-protocol: none Claim: 6587040937 · amendment: 6587055597 (this PR is leg 2 only; the record route's `/meta/page` list, leg 1, is out of this claim and stays on the card) ## 维护者速读 - 改了什么:在控制台里创建的应用(没有包)打开根路径 `/apps/应用名` 时,不再下载整张对象注册表,只按名读取导航条目 `requiresObject` 要求的对象(命令面板打开时再加上导航里的对象),与该应用的对象路由读法一致。 - 为什么改:在 app-crm 上打开这种应用的根路径,落到第一个导航条目之前要先下载 553 KB 的全部对象定义。 - 风险与回滚:根路径从「整表一次」变成「按名多次」:请求数 24 → 33,字节数 824,793 → 367,941 B,界面文字逐字不变。在根路径上打开记录抽屉(`?recordId=`)仍按原样读注册表(#27626)。回滚方法是 revert 本 PR。 - 先看:`ui/packages/app-shell/src/console/appObjectScope.ts` 中 `readAppObjectScope` 的无包分支,以及 `readObjectRouteNames` 的 `read`(空名字列表不再落到整表读取)。 - 两分钟验证:新建一个不带包的应用,打开 `/apps/应用名`,在开发者工具 Network 里过滤 `meta/object`:应该没有不带名字的 `GET /api/v1/meta/object`,页面照常跳到第一个导航条目。 - 需要你做的:无,席位落地。 ## What changes An app created in the console has no `_packageId`. Its root route (`/apps/APP`, no object in the path) had no object scope: `readAppObjectScope` returned `scope: null`, so `AppContent` read `metadata.objects`, the whole object registry, before `AppRootLanding` navigated to the app's first entry. - `appObjectScope.ts`, the package-less branch of `readAppObjectScope`: the root now reads through `readObjectRouteNames` with no route object, as a packaged app's root already does through its package scope. That is the app's `requiresObject` names (navigation and areas) and, while the command palette is open, the navigation's object names, by name through the names-only scope. Only the route's object holds a route, so the root waits for nothing. - `readObjectRouteNames`'s `read`: with no package and no name it answers the empty, settled scope (`NO_OBJECT_SCOPE`) instead of calling `readObjectScope({ names: [] })`. The provider treats an empty names-only ask as the whole registry (`namesOnly` needs a non-empty list), so without this guard the root's empty route read would have read the registry anyway. `readPageObjectScope` already guards the same way. - Unchanged: a record drawer over the root keeps its registry read beside the scope (`drawerReadsRegistry`, #27626); a record form overlay (`?form=`) still has no scope candidate; a packaged root is untouched; `AppContent.tsx` is not edited (`readsWholeRegistry` follows from the scope). ## Tests New `ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRoot-29552.test.tsx`, on the harness of `AppContent.packagelessReads-29416`: the real `MetadataProvider`, `AppContent`, `PageView` and sidebar gate (`useNavMemberGates`) over a client double that records each call. - (a) A package-less root with no landing (its only entry is a link requiring `note`) asks `object/note` by name and no list. - (b) A package-less root whose first entry is a bundled page lands on it, and no list is asked on the way. - (c) CONTROL: with the lists already loaded, the root asks nothing by name. - (d) CONTROL: a record drawer over the root (`?recordId=`) still reads the registry. ## Acceptance notes - Requests vs bytes on the root: 24 → 33 requests, 824,793 → 367,941 B. The extra requests are by-name reads that replace one 553,666 B `/meta/object` list: the landing route's own names and the Setup app's `requiresObject` `sys_*` names from the nav gate's union, which the whole list used to answer. - Leg 1 of the card is not in this PR (claim amendment 6587055597). Measured at base `dbca3ef0`, the record route's first `/meta/page` list initiator is `AppContent`'s record-page reader (`get pages`, called from `readAppObjectScope`). Two more readers share the cached list, from source: `RecordDetailView.tsx:1049` (`usePageAssignment`, which calls `ensureType('page')` and scans `meta.pages`) and `RecordDetailView.tsx:1050` (the named-page check). The default record page is picked by a scan (`type === 'record'` and `object`, `isDefault` first, else the first in served order); no object-side name exists. The app bundle (`packages/rest/src/meta-app-bundle-routes.ts:285`) holds every page bound to a bundled object, so it can answer the pick for those objects, but reaching it from `@object-ui/react`'s `usePageAssignment` needs a new `MetadataContextValue` member. The seat records that design on the card. - The record route also reads the whole registry when the object has no declared record page (its synthesized page derives related lists from every object pointing at it). Unchanged here, as designed (objectui#13046). - `/meta/view` (7,014 B) on the landing object route is `ObjectView`'s saved-views overlay, #29550. ## Verification <details><summary>Production Console, app-crm (<code>objectstack dev --fresh --seed-admin</code>), before at base <code>dbca3ef0</code>, after at <code>335742fa</code></summary> Setup: a runtime app `ops_app` with no package, saved through `PUT /api/v1/meta/app/ops_app` with entries `crm_account`, `crm_contact` (objects) and `crm_welcome` (page). Playwright signs in, opens each route, goes to `about:blank`, then opens the route again with the cache disabled, logging every `/api/` request with its CDP initiator stack (`Debugger.setAsyncCallStackDepth 64`). A fresh navigation, not a reload: a reload would reload the URL the root redirected to. Root `/_console/apps/ops_app` (lands on `/crm_account`), en: - Before: 24 requests, 824,793 B. `GET /api/v1/meta/object` 553,666 B, initiator `get objects` in `AppContent` (the `readsWholeRegistry` arm of `allObjects`), and `/meta/view` 7,014 B (`listViews`). - After: 33 requests, 367,941 B. No `GET /api/v1/meta/object` without a name; the `/meta/view` read is unchanged. - Visible text identical before and after. Record route `/_console/apps/ops_app/crm_account/record/ID` (unchanged by this PR, leg 1): 23 requests, 888,328 B at both heads, with `/meta/page` 15,463 B, `/meta/object` 553,666 B and `/meta/view` 7,014 B. </details> <details><summary>Ablations (each through <code>scripts/ablation-replace.mjs</code>, restored with <code>git diff HEAD</code> empty, at <code>335742fa</code>)</summary> - A1: a package-less root gets no scope again (`if (!app || !route.objectName)`). (a) and (b) red, 2 of 4. - A2: `readObjectRouteNames` asks `readObjectScope({ names: [] })` with no package. (a) and (b) red, 2 of 4. </details> <details><summary>Local runs</summary> - Consumer sweep at `335742fa`, under `os-verify-lock` with `--maxWorkers=2`: the 141 app-shell test files that name `AppContent`, `appObjectScope`, `readAppObjectScope`, `useAppObjectScopePrefetch` or `readObjectScope`, in three batches: 50 files / 391 tests, 45 / 383, 46 / 341, all passed. - After merging main, at `4ac31922`: `turbo run type-check --filter=@object-ui/app-shell` 65/65 (also 65/65 at `335742fa`); the new test, `AppContent.packagelessReads-29416`, `appObjectScope-12811`, `AppContent.objectScope-12811`, `packagelessChromeScope-12980` and `scopedRegistryReaders-12811`: 6 files, 31 tests, all passed. - `eslint` on the changed files: 0 errors (warnings are existing `any`s in `appObjectScope.ts`). - Derived gates (`dispatch-gates --commands` at `335742fa`): 34 commands, all exit 0, reconciled with `--ran`. `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped. Main's 22 incoming commits touch none of this diff's files. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jTUtDJFjiE2P4EstZz --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTUtDJFjiE2P4EstZz)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e43d326 commit 0a4e74e

3 files changed

Lines changed: 311 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(app-shell): the root of an app built in the console reads the objects its entries require by name
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An app created in the console has no package. Opening its root (`/apps/APP`) read the whole object registry before it landed on the app's first entry: 553 KB on the CRM example. The root now reads, by name, only the objects the app's entries require (`requiresObject`), plus the navigation's objects while the command palette is open, the same names an object route of that app already reads. A root with nothing to ask reads nothing. A record drawer opened over the root still reads the registry for its related lists, and once something else has loaded the registry the root reads from it and asks nothing more.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29552 — the root of an app created in the console (its served
5+
* item names no package) reads the objects its entries require by name,
6+
* through the names-only scope, never the whole object registry.
7+
*
8+
* The harness is the one `AppContent.packagelessReads-29416` uses: the REAL
9+
* `MetadataProvider` under the real `AppContent`, the real `PageView` and the
10+
* sidebar's real capability gate (`useNavMemberGates`), with a client double
11+
* that records each call as its URL would read and an app bundle already in
12+
* hand (the `note` object, its view and the `notes_home` page).
13+
*
14+
* Pins:
15+
* (a) a root that stays on screen (its only entry is a link, so it has no
16+
* landing) asks the object its entry requires by name, and no list;
17+
* (b) a root whose landing is a page lands on it, and no list is asked on
18+
* the way;
19+
* (c) CONTROL: with the lists already loaded, the root asks nothing by name;
20+
* (d) CONTROL: a record drawer over the root keeps its registry read
21+
* (objectstack#27626).
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
26+
import React from 'react';
27+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
28+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
29+
import { AuthProvider, ActiveOrganizationStorage } from '@object-ui/auth';
30+
import type { AuthClient, AuthOrganization } from '@object-ui/auth';
31+
32+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
33+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
34+
CreateAppPage: () => null,
35+
EditAppPage: () => null,
36+
DashboardDesignPage: () => null,
37+
}));
38+
// The chrome: the sidebar's capability gate, over the app list's union.
39+
vi.mock('../../layout/ConsoleLayout', async () => {
40+
const { useNavMemberGates } = await import('../../hooks/useNavMemberGates');
41+
return {
42+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => {
43+
const gates = useNavMemberGates();
44+
return (
45+
<div data-testid="console-layout">
46+
<span data-testid="gone-entry">{String(gates.checkCapability('object', 'gone_obj'))}</span>
47+
{children}
48+
</div>
49+
);
50+
},
51+
};
52+
});
53+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
54+
vi.mock('../../hooks/useStudioDesignGate', () => ({ useStudioDesignGate: () => null }));
55+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
56+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
57+
// The object list: the object documents it is handed, and the route object's view keys.
58+
vi.mock('../../views/ObjectView', () => ({
59+
ObjectView: ({ objects }: { objects: Array<{ name: string; listViews?: Record<string, unknown> }> }) => (
60+
<div
61+
data-testid="object-view"
62+
data-views={Object.keys(objects.find((o) => o.name === 'note')?.listViews ?? {}).join(',')}
63+
>
64+
{objects.map((o) => o.name).join(',')}
65+
</div>
66+
),
67+
}));
68+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
69+
...(await importOriginal<Record<string, unknown>>()),
70+
useObjectTranslation: () => ({
71+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
72+
}),
73+
useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label, objectPluralLabel: () => '' }),
74+
}));
75+
76+
const dataSourceStub = {
77+
onConnectionStateChange: () => () => {},
78+
getConnectionState: () => 'connected',
79+
};
80+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
81+
...(await importOriginal<Record<string, unknown>>()),
82+
useAdapter: () => dataSourceStub,
83+
}));
84+
85+
const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) };
86+
vi.mock('@object-ui/react', async (importOriginal) => {
87+
const actual = await importOriginal<typeof import('@object-ui/react')>();
88+
return {
89+
...actual,
90+
useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }),
91+
useGlobalUndo: () => {},
92+
useMutationInvalidationBridge: () => {},
93+
// Stands in for the page's blocks: it names the page it was handed.
94+
SchemaRenderer: ({ schema }: { schema: { label?: string } }) => <div data-testid="page-blocks">{schema.label}</div>,
95+
};
96+
});
97+
98+
import { AppContent } from '../AppContent';
99+
import { MetadataProvider, useMetadata } from '../../providers/MetadataProvider';
100+
101+
type Doc = { name: string; [key: string]: unknown };
102+
103+
const OBJECTS: Doc[] = [
104+
{ name: 'note', label: 'Note', fields: { owner: { type: 'lookup', reference: 'sys_user' } } },
105+
{ name: 'crm_account', _packageId: 'app.crm', label: 'Account', fields: {} },
106+
{ name: 'sys_user', _packageId: 'plugin.auth', label: 'User', fields: {} },
107+
];
108+
const VIEWS: Doc[] = [
109+
{ name: 'note.all', object: 'note', viewKind: 'list', label: 'All notes', isDefault: true, config: { type: 'grid', columns: [] } },
110+
{ name: 'crm_account.all', object: 'crm_account', viewKind: 'list', label: 'All accounts', isDefault: true, config: { type: 'grid', columns: [] } },
111+
];
112+
const PAGES: Doc[] = [
113+
{ name: 'notes_home', label: 'Notes home', type: 'app', regions: [] },
114+
{ name: 'outside_page', label: 'Outside page', type: 'app', regions: [] },
115+
// Two packages ship `home`; the server's by-name read answers the first.
116+
{ name: 'home', _packageId: 'app.other', label: 'Other home', type: 'app', regions: [] },
117+
{ name: 'home', _packageId: 'app.crm', label: 'CRM home', type: 'app', regions: [] },
118+
{ name: 'crm_only', _packageId: 'app.crm', label: 'CRM only', type: 'app', regions: [] },
119+
];
120+
121+
const objectEntry = (name: string, requires?: string) => ({
122+
id: `nav_${name}`, type: 'object', label: name, objectName: name, ...(requires ? { requiresObject: requires } : {}),
123+
});
124+
125+
/** The console-created app: no `_packageId`. */
126+
const NOTES_APP = {
127+
name: 'notes',
128+
label: 'Notes',
129+
navigation: [objectEntry('note'), { id: 'nav_home', type: 'page', label: 'Home', pageName: 'notes_home' }, objectEntry('gone_obj', 'gone_obj')],
130+
};
131+
/** Another app the list carries: it requires an object outside the notes bundle. */
132+
const CRM_APP = { name: 'crm', label: 'CRM', _packageId: 'app.crm', navigation: [objectEntry('crm_account', 'crm_account')] };
133+
134+
/** A console-created app whose only entry is a link that requires `note`: its root has no landing, and stays. */
135+
const LINKS_APP = {
136+
name: 'links',
137+
label: 'Links',
138+
navigation: [{ id: 'nav_site', type: 'url', label: 'Site', url: 'https://example.com', requiresObject: 'note' }],
139+
};
140+
/** A console-created app whose first entry is a page the bundle holds: its root lands on it. */
141+
const HOME_FIRST_APP = {
142+
name: 'home_first',
143+
label: 'Home first',
144+
navigation: [{ id: 'nav_home', type: 'page', label: 'Home', pageName: 'notes_home' }, objectEntry('note', 'note')],
145+
};
146+
147+
/** The notes app's bundle, keyed as the adapter's `AppBundleItems` keys it. */
148+
function bundleItems() {
149+
const items = new Map<string, Map<string, { item: unknown; envelope: Record<string, unknown> }>>();
150+
const put = (type: string, docs: Doc[]) => {
151+
const byName = new Map();
152+
for (const doc of docs) byName.set(doc.name, { item: structuredClone(doc), envelope: { type, name: doc.name, item: doc } });
153+
items.set(type, byName);
154+
};
155+
put('object', [OBJECTS[0]]);
156+
put('view', [VIEWS[0]]);
157+
put('page', [PAGES[0]]);
158+
put('dashboard', []);
159+
return items;
160+
}
161+
162+
const server = { calls: [] as string[] };
163+
164+
const notFound = () => Promise.reject(Object.assign(new Error('not found'), { httpStatus: 404, code: 'RESOURCE_NOT_FOUND' }));
165+
166+
function makeAdapter() {
167+
const bundle = { status: 'ready', items: bundleItems() };
168+
return {
169+
clearCache: vi.fn(),
170+
invalidateCache: vi.fn(),
171+
peekAppBundle: () => bundle,
172+
getObjectSchema(this: { getClient(): any }, name: string) {
173+
return this.getClient()
174+
.meta.getItem('object', name)
175+
.then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res));
176+
},
177+
getClient: () => ({
178+
meta: {
179+
getItems: (type: string, options?: { packageId?: string }) => {
180+
server.calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type);
181+
const all: Doc[] =
182+
type === 'object' ? OBJECTS : type === 'view' ? VIEWS : type === 'page' ? PAGES : type === 'app' ? [NOTES_APP, CRM_APP, LINKS_APP, HOME_FIRST_APP] : [];
183+
const items = options?.packageId ? all.filter((o) => o._packageId === options.packageId) : all;
184+
return Promise.resolve({ type, items: structuredClone(items) });
185+
},
186+
getItem: (type: string, name: string) => {
187+
server.calls.push(`${type}/${name}`);
188+
const all = type === 'object' ? OBJECTS : type === 'page' ? PAGES : [];
189+
// ADR-0048 §3.3: a context-free by-name read answers the first match.
190+
const item = all.find((o) => o.name === name);
191+
return item ? Promise.resolve({ type, name, item: structuredClone(item) }) : notFound();
192+
},
193+
},
194+
}),
195+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
196+
}
197+
198+
const USER = 'u_29552';
199+
const ORG: AuthOrganization = { id: 'org_a', name: 'Org A', slug: 'a' };
200+
201+
function authClient(): AuthClient {
202+
return {
203+
getSession: vi.fn(async () => ({
204+
user: { id: USER, name: 'Ada', email: 'ada@test.com', role: 'admin', positions: ['admin'] },
205+
session: { token: 'tok-29552', activeOrganizationId: ORG.id },
206+
})),
207+
signOut: vi.fn(async () => undefined),
208+
getConfig: vi.fn(async () => ({ features: {} })),
209+
listOrganizations: vi.fn(async () => [ORG]),
210+
getActiveOrganization: vi.fn(async () => ORG),
211+
setActiveOrganization: vi.fn(async () => ORG),
212+
getActiveMember: vi.fn(async () => ({ id: 'm1', organizationId: ORG.id, userId: USER, role: 'owner' })),
213+
} as unknown as AuthClient;
214+
}
215+
216+
/** Reads the whole object, view and page lists at mount, as Studio would have. */
217+
function LoadLists() {
218+
const metadata = useMetadata();
219+
void metadata.objects;
220+
void metadata.pages;
221+
return null;
222+
}
223+
224+
function boot(url: string, options: { listsLoaded?: boolean } = {}) {
225+
render(
226+
<AuthProvider authUrl="/api/auth" client={authClient()}>
227+
<MetadataProvider adapter={makeAdapter()}>
228+
{options.listsLoaded ? <LoadLists /> : null}
229+
<MemoryRouter initialEntries={[url]}>
230+
<Routes>
231+
<Route path="/apps/:appName/*" element={<AppContent />} />
232+
</Routes>
233+
</MemoryRouter>
234+
</MetadataProvider>
235+
</AuthProvider>,
236+
);
237+
}
238+
239+
/** The calls that read a whole list of one of the three types. */
240+
const listCalls = () => server.calls.filter((c) => c === 'object' || c === 'view' || c === 'page');
241+
const byNameCalls = () => server.calls.filter((c) => c.startsWith('object/') || c.startsWith('page/'));
242+
243+
beforeEach(() => {
244+
server.calls = [];
245+
localStorage.clear();
246+
sessionStorage.clear();
247+
ActiveOrganizationStorage.clear();
248+
});
249+
250+
afterEach(() => {
251+
cleanup();
252+
ActiveOrganizationStorage.clear();
253+
});
254+
255+
describe('a package-less app’s root reads the objects it requires by name (objectstack#29552)', () => {
256+
it('(a) a root with no landing asks the object its entry requires by name, and no list', async () => {
257+
boot('/apps/links');
258+
await waitFor(() => expect(screen.getByTestId('gone-entry')).toHaveTextContent('false'));
259+
await waitFor(() => expect(byNameCalls()).toContain('object/note'));
260+
await new Promise((resolve) => setTimeout(resolve, 50));
261+
262+
expect(screen.queryByTestId('object-view')).toBeNull();
263+
expect(listCalls()).toEqual([]);
264+
});
265+
266+
it('(b) a root whose landing is a page lands on it, and no list is asked on the way', async () => {
267+
boot('/apps/home_first');
268+
await waitFor(() => expect(screen.getByTestId('page-blocks')).toHaveTextContent('Notes home'));
269+
await waitFor(() => expect(screen.getByTestId('gone-entry')).toHaveTextContent('false'));
270+
271+
expect(byNameCalls()).toContain('object/note');
272+
expect(listCalls()).toEqual([]);
273+
});
274+
275+
it('(c) CONTROL: with the lists already loaded, the root asks nothing by name', async () => {
276+
boot('/apps/links', { listsLoaded: true });
277+
await waitFor(() => expect(screen.getByTestId('gone-entry')).toHaveTextContent('false'));
278+
await new Promise((resolve) => setTimeout(resolve, 50));
279+
280+
expect(byNameCalls()).toEqual([]);
281+
});
282+
283+
it('(d) CONTROL: a record drawer over the root keeps its registry read (objectstack#27626)', async () => {
284+
boot('/apps/links?recordId=n1');
285+
await waitFor(() => expect(listCalls()).toContain('object'));
286+
});
287+
});

‎ui/packages/app-shell/src/console/appObjectScope.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@
5858
*
5959
* An app item without a package id (an app created in the console) has no
6060
* package to scope by: an object's route reads the same names by name through
61-
* the names-only scope (objectstack#29416), and its root keeps the whole
62-
* registry.
61+
* the names-only scope (objectstack#29416), and so does its root
62+
* (objectstack#29552): the objects the app's entries require, and the
63+
* navigation's while the command palette is open, never the whole registry.
64+
* The root lands on its first entry, which reads its own names; a root with
65+
* no name to ask reads nothing.
6366
*
6467
* Two readers ask the same question with the same answer: `AppContent`, which
6568
* renders the route, and the console root (`ConsoleShell`), which asks it as
@@ -518,9 +521,12 @@ export function readAppObjectScope(
518521
// objectstack#29416 — an app with no package: an object's route reads its
519522
// names by name through the names-only scope (the route's object, its
520523
// lookup targets and the app's `requiresObject` entries), as an app whose
521-
// package holds no objects does below. The app's root keeps the whole
522-
// registry, as there. Until the app list names the app, nothing is read.
523-
if (!app || !route.objectName) return { candidate, scope: null, ready: false, ...NOT_READ };
524+
// package holds no objects does below. objectstack#29552 — so does the
525+
// app's root, with no route object: measured on a production Console, a
526+
// console-created app's root read the whole object registry (553 KB on
527+
// app-crm) before it landed on its first entry. Until the app list names
528+
// the app, nothing is read.
529+
if (!app) return { candidate, scope: null, ready: false, ...NOT_READ };
524530
return { candidate, ...readObjectRouteNames(readObjectScope, app, route.objectName, search, undefined) };
525531
}
526532
// The package list first: no name is asked until it has answered.
@@ -551,8 +557,10 @@ function readObjectRouteNames(
551557
search: string,
552558
packageId: string | undefined,
553559
): Omit<AppObjectScopeRead, 'candidate'> {
560+
// With no package and no name, nothing: the provider reads an empty
561+
// names-only ask as the whole registry (objectstack#29552, an app's root).
554562
const read = (names: readonly string[]): AppObjectScope =>
555-
packageId ? readObjectScope({ packageId, names }) : readObjectScope({ names });
563+
packageId ? readObjectScope({ packageId, names }) : names.length > 0 ? readObjectScope({ names }) : NO_OBJECT_SCOPE;
556564
const routeRead = read(objectName ? [objectName] : []);
557565
const ready = routeReadDone(routeRead);
558566
const routeOutcome = { failedNames: failedNamesOf(routeRead), routeObjectRefusal: refusalOf(routeRead, objectName) };

0 commit comments

Comments
 (0)