Skip to content

Commit ff827bc

Browse files
perf(app-shell): a console-created app's object and page routes read what they name by name (#29542)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbYLBU9GQbwZ53pqtiGNhgqR)_ Fixes #29416 Clause-②: no Metadata-protocol: none Claim: 6581096637 · amendments: 6581167613, 6581536193 ## 维护者速读 - 改了什么:在控制台里创建的应用(没有包)里,三处读取不再下载整张元数据列表。侧边栏的能力门按名读对象,不再带视图。对象路由按名读本对象、`requiresObject` 和查找字段目标,不再读整张对象注册表。应用包(bundle)里没有的页面按名读单条,不再读整张 `/meta/page` 列表。 - 为什么改:在 app-crm 上打开这种应用的一个对象页,每次都要下载 551 KB 的全部对象定义和 7 KB 的视图列表。打开包外页面时,还会额外下载 15 KB 的页面列表和视图列表。 - 风险与回滚:读法从「整表一次」变成「按名多次」。对象页的请求数从 24 增加到 33,但字节数从 821 KB 降到 366 KB。界面文字逐字不变,视图页签和面包屑的标签已在测试中逐字节钉住。在有包的应用里,如果包外页面的按名回答属于另一个包或者不存在,会先多一次按名请求,再回退到整表(ADR-0048 preferLocal)。回滚方法是 revert 本 PR。 - 先看:`ui/packages/app-shell/src/console/appObjectScope.ts`(`readObjectRouteNames`)、`ui/packages/app-shell/src/hooks/useObjectPresence.ts`(`readItemPreferLocal`)、`ui/packages/app-shell/src/providers/MetadataProvider.tsx:1744`(按名读取只组合提问者所问名字的视图)。 - 两分钟验证:新建一个不带包的应用,打开它的对象页,在开发者工具 Network 里过滤 `meta/object`:应该没有不带名字的 `GET /api/v1/meta/object`。再打开该应用里一个包外页面:应该是 `GET /api/v1/meta/page/<名字>`,没有 `/meta/page` 整表,也没有 `/meta/view`。 - 需要你做的:无,席位落地。 ## What changes An app created in the console has no `_packageId`. On such an app, three readers still downloaded a whole metadata list. A page outside the app bundle had the same problem in any app. | Leg | Before | Now | |---|---|---| | 1. `useNavMemberGates` inside a package-less app | `readObjectScope({ names: unionNames })` **with** views. The union names other apps' objects outside this bundle, so composing their views read the whole `/meta/view` list. | `{ names: unionNames, views: false }`, the same call it already made outside an app and on the package leg. Nothing in the app reads views off this presence answer. | | 1'. `MetadataProvider` names-only compose (probe-proved, amendment 6581536193) | The names-only entry is shared by every names-only reader, and a with-views read composed the views of **every** name the entry held, so another reader's union leaked in. | It composes the views of the names its own reader asked (`MetadataProvider.tsx:1744`). | | 2. `readAppObjectScope` for a package-less app's object route | No scope: the whole object registry. | `readObjectRouteNames` (`appObjectScope.ts:547`) reads the route's object, the app's `requiresObject` names and the palette's nav objects by name. It reads the object's lookup targets by name with `views: false`. Until the app list names the app, nothing is read (a candidate without a known package must not ask early). The app's root still reads the whole registry, as before. | | 2'. `useServedViewItems` names-only branch (amendment 6581536193) | The served names were the app's `requiresObject` only, so a route object named only by a navigation entry lost its served view labels once the registry was no longer loaded. | `packagelessAppObjectNames(app)`: navigation `type: 'object'` names plus `requiresObject`, across `navigation` and every `areas[].navigation`. | | 3. A page the app bundle does not hold: `AppContent`'s page scope, `PageView`, `AppHeader`'s phone title | `preferLocal(metadata.pages, name, _packageId)`: the whole `/meta/page` list. | `readItemPreferLocal` (`useObjectPresence.ts:190`), built on `useReadItemsByName` from #29413. While the type is idle, it asks the page by name. It takes the answer when the app has no package, or when the answer is the app package's own. Otherwise (another package's page, or no page) it falls back to the list, so the item is always the one `preferLocal` would pick. Once the list is loading or loaded, it reads the list and asks nothing. | ## Tests - `console/__tests__/AppContent.packagelessReads-29416.test.tsx` (7). These use the real `AppContent`, `PageView` and `MetadataProvider`, with a recording adapter and a ready app bundle. - (a) A package-less object route reads no `object`, `view` or `page` list. - (b) An out-of-bundle page is read by name, and a bundled page asks nothing. - (c) CONTROL: with the lists loaded, nothing more is asked. - (d) preferLocal: in a packaged app, another package's answer falls back to the list and draws the app's own page. The app's own answer reads no list. A package-less app takes the first match. - `hooks/__tests__/packagelessServedViews-29416.test.tsx` (2). - The route object's views stay served. The `viewTabLabel` labels (tab and crumb) are byte-identical before and after the whole registry loads, and no list is read. - The provider composes views only for the names its reader asked. - `layout/__tests__/AppHeader.pageTitleByName-29416.test.tsx` (2). The phone title asks the page by name with no list read, plus a CONTROL with the list loaded. - Updated pins whose CONTROL encoded the old whole-list read: - `appObjectScope-12811`: an app without a package now expects `object/crm_account`, `object/sys_user`. - `AppContent.objectScope-12811`: the route object and its lookup target by name. - `AppContent.pageRouteObjects-12980`: the double serves pages by name. - The census `openPathObjectReaders-12811` is unchanged and green. No row moved. ## Acceptance notes - Requests vs bytes on the object route: 24 → 33 requests, 821,430 → 365,762 B. The nine extra requests are by-name reads that replace one 551,834 B `/meta/object` list: `sys_user` and `sys_organization` (the lookup targets), and the Setup app's `requiresObject` sys_* names from the nav gate's union, which the whole list used to answer. - A page in a packaged app that resolves to another package's page, or to none, now costs one serial by-name read before the list. - Not changed, found while measuring (outside the claim's surface): - Every object route, packaged apps included, still reads `/meta/view` through `ObjectView`'s saved-views overlay (`ui/packages/app-shell/src/views/ObjectView.tsx:416`, `dataSource.listViews(objectName)` → the adapter's `readViewList`). On this route it is now the only `/meta/view` reader. - A packaged app's object route whose lookup target is outside the bundle still reads `/meta/view` through the package scope's with-views lookup read. - A package-less app's record route still reads the `/meta/page` list (`readPages`). Its root still reads the whole registry. - Merging main (#29472, dashboard route reads) needed two resolution fixes in the merge commit: a duplicated `ReadNamesWithoutViews` import, and the `preferLocal` import that main's new dashboard read uses. ## Verification <details><summary>Production Console, app-crm (<code>objectstack dev --seed-admin --fresh</code>), before (app-shell sources at base <code>5f7d554c</code>) and after (head <code>bd111303</code>; later commits only merge main)</summary> Setup: - A runtime app `ops_app` with no package, saved through `PUT /api/v1/meta/app/ops_app` with two object entries (`crm_account`, `crm_contact`). `crm_welcome` is not in its navigation, so it is outside the bundle. - Playwright signs in and opens each route. It then reloads with the cache disabled and logs every `/api/` request with its CDP initiator stack (`Debugger.setAsyncCallStackDepth 64`). Object route `/apps/ops_app/crm_account`, en: - Before: 24 requests, 821,430 B. It reads `GET /api/v1/meta/object` (551,834 B) and `/meta/view` (7,014 B), both from `get objects`. - After: 33 requests, 365,762 B. There is no `GET /api/v1/meta/object` without a name. `/meta/view` (7,014 B) is still read, and its only initiator is `ObjectView`'s `listViews` (see Acceptance notes). - For comparison, the packaged `crm_app` object route after: `/meta/object?package=com.example.crm` and `/meta/view` from `appObjectScope`'s lookup read (unchanged). Out-of-bundle page route `/apps/ops_app/page/crm_welcome`, en: - Before: 29 requests, 335,061 B. It reads `GET /api/v1/meta/page` (15,463 B, from `get pages`) and `/meta/view` (7,014 B, the nav gate's names-only scope). - After: 28 requests, 313,334 B. It reads `GET /api/v1/meta/page/crm_welcome` (1,044 B). There is no `/meta/page` list and no `/meta/view`. The visible text of both routes is identical before and after, including the view tab `All Records` and the page body. </details> <details><summary>Ablations (each through <code>scripts/ablation-replace.mjs</code>, restored with <code>git diff HEAD</code> empty, at <code>bd111303</code>)</summary> - A1: the in-app gate asks with views. 3 red. - A2: a package-less app gets no scope. 2 red. - A3: `readObjectRouteNames` always reads the list. 5 red. - A4: the provider composes every name the entry holds. 4 red. - A5: the served names are `requiresObject` only. 1 red. - A6: `readItemPreferLocal` drops the ownership check. 1 red ((d), the other package's answer). - A7: package-less lookups asked with views. 1 red. - A8: `AppHeader` reads the page list. 1 red. - A9: `PageView` reads the page list. 3 red. </details> <details><summary>Local runs</summary> - Consumer sweep at `bd111303`, under `os-verify-lock` with `--maxWorkers=2`: 210 test files naming the touched modules, 1,920 tests, all passed. - After merging main, at `3a894d8b`: - `console/__tests__` and `hooks/__tests__`: 157 files, 1,273 tests. Main's `AppContent.dashboardRouteReads-28784` was red on the dropped `preferLocal` import, then green after the fix. - The `PageView`, `AppHeader`, `AppContent` and `appObjectScope` files plus the new tests: 95 files, 754 tests, all passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65, at both heads. - `eslint --no-inline-config` on the changed files: 0 errors. - Derived gates (`dispatch-gates --commands`): 34 commands, all exit 0 at `bd111303`, reconciled with `--ran` (34 derived, 34 run, 0 NOT-MEASURED). At `3a894d8b`, `check-sdui-manifest` and `check-changeset-no-major` pass. </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_01QsR2jTxHVLQcafzV1G73bX --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTxHVLQcafzV1G73bX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 208d3e2 commit ff827bc

15 files changed

Lines changed: 912 additions & 62 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(app-shell): an app built in the console and a page outside the app bundle read what they name by name
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An app created in the console has no package, and three of its reads still downloaded a whole metadata list:
11+
12+
- Every route of such an app read the whole `/meta/view` list: the sidebar's capability gate composed its union of `requiresObject` names, which names other apps' objects outside this app's bundle, with their views. It now asks those names without views, as it already did outside an app, and a names-only object read composes only the views of the names its reader asked.
13+
- An object's route read the whole object registry. It now reads the route's object, the app's `requiresObject` entries and the object's lookup targets by name; the lookup targets are asked without views. The view tabs and the view crumb keep reading their served labels from the same documents, so labels are unchanged.
14+
- A page the app bundle does not hold (in any app) read the whole `/meta/page` list in the route, the page view and the phone title. It is now read by name, and the list is read only when the by-name answer cannot be the prefer-local pick (ADR-0048): another package's page in a packaged app, or no page at all.
15+
16+
Once something else has loaded a list, these readers read it and ask nothing more.

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { buildExpressionUser } from '../providers/expressionUser.js';
3535
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
3636
import { useHomePath } from '../hooks/useHomePath.js';
37+
import { readItemPreferLocal, useReadItemsByName } from '../hooks/useObjectPresence.js';
3738
import { useLauncherApps } from '../hooks/useLauncherApps.js';
3839
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
3940
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
@@ -343,6 +344,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
343344
// objectstack#28645 — what the app bundle answers in render (null outside a
344345
// `MetadataProvider`): the page a page route opens is read there first.
345346
const bundledReads = useBundledReads();
347+
// objectstack#29416 — a page the bundle does not hold is asked by name.
348+
const readItemsByName = useReadItemsByName(metadata);
346349
// objectui#7373 — where this file's two "you cannot be here" exits land. Both
347350
// sit BELOW the readiness gate further down (`metadataLoading` &c), so the
348351
// list they resolve against has settled; an app list that failed to load is
@@ -481,15 +484,20 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
481484
hostRoutePaths,
482485
(pageName) => {
483486
// objectstack#28645 — the bundle's page when it holds this package's;
484-
// the `/meta/page` list, prefer-local, for a page it does not hold.
487+
// objectstack#29416 — else the page by name, prefer-local, and the
488+
// `/meta/page` list only when the by-name answer cannot be the pick.
485489
const bundled = bundledReads?.item('page', pageName, (activeApp as any)?._packageId);
486490
if (bundled?.state === 'hit') return { page: bundled.value, settled: true };
487491
if (bundled?.state === 'pending') return { page: undefined, settled: false };
488-
const status = getTypeStatus?.('page');
489-
return {
490-
page: preferLocal(metadata.pages as any[], pageName, (activeApp as any)?._packageId),
491-
settled: status === undefined || status === 'ready' || status === 'error',
492-
};
492+
const { item, settled } = readItemPreferLocal(
493+
readItemsByName,
494+
'page',
495+
pageName,
496+
(activeApp as any)?._packageId,
497+
() => metadata.pages,
498+
getTypeStatus?.('page'),
499+
);
500+
return { page: item, settled };
493501
},
494502
// objectui#13046 — a record route reads the objects its declared record
495503
// page binds, picked from the page list as `RecordDetailView` picks it.

‎ui/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
* one `requiresObject` entry, and `useActionModal` is mounted beside it, as
1919
* `ConsoleShell` mounts it.
2020
*
21-
* CONTROLs: an app item with no package id asks the whole registry, as before,
22-
* and so does the root of an app whose package holds no objects of its own (a
23-
* platform app such as Setup; its object routes read by name, objectui#13046);
24-
* a record overlay (`?recordId=`) reads the whole registry, because a record's
21+
* An app item with no package id asks its route's names by name instead
22+
* (objectstack#29416; it read the whole registry).
23+
*
24+
* CONTROLs: the root of an app whose package holds no objects of its own (a
25+
* platform app such as Setup; its object routes read by name, objectui#13046)
26+
* asks the whole registry, as before; a record overlay (`?recordId=`) reads the whole registry, because a record's
2527
* related lists come from every object that points at it (since
2628
* objectstack#27626 the drawer reads it beside the list's scope, and the list
2729
* does not wait for it: `AppContent.recordDrawerKeepsScope-27626.test.tsx`).
@@ -231,14 +233,15 @@ describe('one console open asks for the app\'s objects, not the whole registry (
231233
expect(screen.getByTestId('console-layout').getAttribute('data-chrome-objects')).toBe('crm_account,crm_contact,sys_user');
232234
});
233235

234-
it('CONTROL: an app item with no package id asks the whole registry, as before', async () => {
236+
it('an app item with no package id asks the route object and its lookup target by name, never the whole registry (objectstack#29416)', async () => {
235237
server.apps = [{ name: 'crm', label: 'CRM', navigation: NAVIGATION }];
236238
boot('/apps/crm/crm_account');
237-
await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument());
239+
await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('sys_user'));
238240

239-
expect(objectCalls()).toContain('object');
241+
expect(objectCalls()).not.toContain('object');
240242
expect(objectCalls().some((c) => c.startsWith('object?'))).toBe(false);
241-
expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact,crm_case,sys_user');
243+
expect(objectCalls()).toEqual(expect.arrayContaining(['object/crm_account', 'object/sys_user']));
244+
expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account');
242245
});
243246

244247
it("CONTROL: the root of an app whose package holds no objects of its own reads the whole registry, and asks none by name", async () => {

0 commit comments

Comments
 (0)