Skip to content

Commit 0bfb225

Browse files
perf(app-shell): a packaged app's object route asks its lookup targets without views (#29609)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb99UApg2HDL6vC7wFG5UA5v)_ Fixes #29551 Clause-②: no Metadata-protocol: none Claim: 6586992712 ## 维护者速读 - 改了什么:有包的应用里,对象页读取本对象查找字段的目标对象(例如 `owner` 指向的 `sys_user`)时,改为按名读取且不带视图,与无包应用的做法一致。本对象、`requiresObject` 和导航对象仍然带视图。 - 为什么改:目标对象不在应用包(bundle)里时,带视图的读法会为它下载整张 `/meta/view` 列表(app-crm 上 7,014 B),而查找字段只需要目标对象的定义。 - 风险与回滚:查找目标的定义照旧按名到达并并入对象作用域,查找单元格的链接与名称不变;目标对象不再带视图,实测路由上没有读取查找目标视图的地方。回滚方法是 revert 本 PR。注意:在 #29550 落地前,`ObjectView` 的已存视图读取仍会请求同一张 `/meta/view`,所以 app-crm 上线路字节暂时不变;两者都落地后该列表消失(本地探针已实测)。 - 先看:`ui/packages/app-shell/src/console/appObjectScope.ts` 的 `readObjectRouteNames` 末尾(有包分支的查找目标读取),然后是 `appObjectScope-12811` 测试末尾新增的两个用例。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx`,预期 6 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes In an app whose served item names its package, an object route reads the route object's lookup targets through `readObjectRouteNames`. On the packaged branch it asked them with the route's names through the package scope's with-views read (`read([...names, ...lookups])`). A target outside the app bundle, such as `sys_user` behind `crm_account.owner`, then composed its views from the whole `/meta/view` list. | | Before | Now | |---|---|---| | Packaged app, lookup targets | `readObjectScope({ packageId, names: [...names, ...lookups] })`, with views, returned as the route's scope | `readObjectScope({ packageId, names: lookups, views: false })`; the scope returned is the route's names read, which the targets join as they land (the same package scope entry) | | Package-less app, lookup targets | `{ names: lookups, views: false }` | unchanged | | Route object, `requiresObject` entries, palette nav objects | with views | unchanged, with views | Readers of the lookup targets' views on a packaged object route, checked before the edit: none. A lookup cell links and names its target. `ObjectView`'s saved-views read, `useServedViewItems` and `RecordFormPage` read the route object's views only. A record drawer or form overlay reads the whole registry beside or instead of the scope, as before. `ready` and the route's failure outcome still come from the route object's read alone. ## Tests - `appObjectScope-12811`, two new cases with a ready app bundle that holds `crm_account` and `crm_contact` with their views: - `/apps/crm/crm_account` (lookup `owner` to `sys_user`, outside the bundle): the reads are `object?package=app.crm` and `object/sys_user`, and no `view` list. The scope reads `crm_account[crm_account.all]`, `crm_contact[crm_contact.all]`, `sys_user[]`. - CONTROL `/apps/crm/crm_contact` (lookup to `crm_account`, in the bundle): only `object?package=app.crm`, and the same composed views as before. - Ablation: restoring `return { scope: read([...names, ...lookups]), ... }` on the packaged branch turns the first case red on the extra `view` read; the CONTROL stays green. - `AppContent.objectScope-12811`, `AppContent.packagelessReads-29416` and the `openPathObjectReaders-12811` census need no change and are green. ## Acceptance notes - The bytes on the wire do not drop yet. With this reader gone, the same `/meta/view` list (7,014 B) is now first asked by `ObjectView`'s saved-views read (`dataSource.listViews` to the adapter's `readViewList`), which #29550 is removing. Before this change that read shared the request this reader started. A local probe with that read stubbed showed no `/meta/view` request at all on both routes. - Request counts differ by preference and `security/explain` writes only; the `meta` and `data` reads are the same set. ## Verification <details><summary>Production Console, app-crm (<code>objectstack dev --fresh --seed-admin</code>), base <code>dbca3ef0</code> and fix <code>cb81f443</code></summary> Playwright signs in, opens each route, reloads with the cache disabled, and logs every `/api/` request with its CDP initiator stack (async depth 64). The `crm_app` bundle holds the CRM objects; `crm_account` and `crm_contact` look up `sys_user`, `sys_organization` and `sys_business_unit`, which it does not hold. `/apps/crm_app/crm_account`, en: - Before: 30 requests, 1,033,677 B. `GET /api/v1/meta/view` (7,014 B), initiator the packaged lookup read in `readObjectRouteNames`, called from `useAppObjectScopePrefetch`. - After: 28 requests, 1,033,049 B. `GET /api/v1/meta/view` (7,014 B), initiator `ObjectView`'s `listViews` to `readViewList`; nothing from `appObjectScope`. - Probe, after plus `ObjectView`'s `listViews` stubbed (not committed): 28 requests, 1,027,054 B, no `/meta/view`. `/apps/crm_app/crm_contact`, en: - Before: 28 requests, 1,034,072 B, `/meta/view` from the same packaged lookup read. - After: 28 requests, 1,034,072 B, `/meta/view` from `ObjectView`'s `listViews` only. - Probe: 27 requests, 1,027,058 B, no `/meta/view`. Both routes read `meta/object/sys_organization`, `sys_user` and `sys_business_unit` by name before and after. </details> <details><summary>Local runs</summary> - Ablation through `scripts/ablation-replace.mjs` (anchor 1 to 0, restored, blob equals HEAD, `git diff HEAD` empty): 1 failed, 5 passed. - Sweep at `cb81f443` under `os-verify-lock`, `--maxWorkers=2`: 139 test files naming the scope readers or `AppContent`, 1,092 tests, all passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 at `cb81f443` and at `fadf5b2f` (merge of main). - `eslint` on the changed files: 0 errors. - Derived gates (`dispatch-gates --commands`) at `cb81f443`: 34 commands, all exit 0, reconciled with `--ran` (34 derived, 34 run, 0 NOT-MEASURED). - At `fadf5b2f`: the four scope test files (23 tests) pass; `check-sdui-manifest`, `check-changeset-no-major` and spec `check:generated` exit 0. </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_01QsR2jUpBc8NqS3hJsBsz7W --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUpBc8NqS3hJsBsz7W)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a81b7cb commit 0bfb225

3 files changed

Lines changed: 135 additions & 13 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(app-shell): a packaged app's object route asks its lookup targets without views
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An object route in an app that ships in a package read the route object's lookup targets through the package scope's with-views read. A target outside the app bundle, such as `sys_user` behind an `owner` lookup, then had its views composed from the whole `/meta/view` list.
11+
12+
- **Now.** The lookup targets are asked by name without views, as an app without a package already asked them. They join the route's object scope as their definitions land, so lookup cells link and name their targets as before.
13+
- **Unchanged.** The route's object, the app's `requiresObject` entries and the navigation's objects keep their views. A lookup target the package already holds asks nothing more.

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

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@
1616
* id asks the route's object and its lookup targets by name, never the whole
1717
* registry (objectstack#29416). A record drawer (`?recordId=`) asks the scope of the list under it,
1818
* as `AppContent` does (objectstack#27626).
19+
*
20+
* objectstack#29551 — inside a packaged app whose bundle is in hand, the route
21+
* object's lookup targets are asked without views: a target outside the bundle
22+
* (`sys_user`) joins the scope as a served definition and reads no
23+
* `/meta/view` list, while the route's object keeps the bundle's views.
24+
* CONTROL: a target the bundle holds reads nothing more and keeps its views.
1925
*/
2026

2127
import { describe, it, expect, vi, afterEach } from 'vitest';
2228
import { cleanup, render, waitFor } from '@testing-library/react';
23-
import { MemoryRouter } from 'react-router-dom';
29+
import { MemoryRouter, useLocation } from 'react-router-dom';
2430
import { AdapterCtx } from '@object-ui/react';
25-
import { MetadataProvider } from '../../providers/MetadataProvider';
26-
import { enterAppBundle, useAppObjectScopePrefetch } from '../appObjectScope';
31+
import { MetadataProvider, useMetadata } from '../../providers/MetadataProvider';
32+
import { enterAppBundle, readAppObjectScope, useAppObjectScopePrefetch } from '../appObjectScope';
2733

2834
const CRM = 'app.crm';
2935
const OBJECTS = [
@@ -143,3 +149,105 @@ describe('the console root starts the app object scope (objectui#12811)', () =>
143149
expect([...objectCalls(noPackage)].sort()).toEqual(['object/crm_account', 'object/sys_user']);
144150
});
145151
});
152+
153+
describe('objectstack#29551 — a packaged app asks its lookup targets without views', () => {
154+
type Doc = { name: string; [key: string]: unknown };
155+
const BUNDLED: Doc[] = [
156+
{ name: 'crm_account', _packageId: CRM, fields: { owner: { type: 'lookup', reference: 'sys_user' } } },
157+
{ name: 'crm_contact', _packageId: CRM, fields: { account: { type: 'lookup', reference: 'crm_account' } } },
158+
];
159+
const ALL: Doc[] = [...BUNDLED, { name: 'sys_user', _packageId: 'plugin.auth', fields: {} }];
160+
const VIEWS: Doc[] = [
161+
{ name: 'crm_account.all', object: 'crm_account', viewKind: 'list', label: 'All accounts', isDefault: true, config: { type: 'grid', columns: [] } },
162+
{ name: 'crm_contact.all', object: 'crm_contact', viewKind: 'list', label: 'All contacts', isDefault: true, config: { type: 'grid', columns: [] } },
163+
{ name: 'sys_user.all', object: 'sys_user', viewKind: 'list', label: 'All users', isDefault: true, config: { type: 'grid', columns: [] } },
164+
];
165+
166+
/** The crm app's bundle, as the adapter's `peekAppBundle` answers once the app was entered: its two objects and their views. */
167+
function bundle() {
168+
const items = new Map<string, Map<string, { item: unknown; envelope: Record<string, unknown> }>>();
169+
const put = (type: string, docs: Doc[]) => {
170+
const byName = new Map();
171+
for (const doc of docs) byName.set(doc.name, { item: structuredClone(doc), envelope: { type, name: doc.name, item: doc } });
172+
items.set(type, byName);
173+
};
174+
put('object', BUNDLED);
175+
put('view', VIEWS.slice(0, 2));
176+
return { status: 'ready', items };
177+
}
178+
179+
function makeBundledAdapter(calls: string[]) {
180+
const held = bundle();
181+
return {
182+
clearCache: vi.fn(),
183+
invalidateCache: vi.fn(),
184+
peekAppBundle: () => held,
185+
getObjectSchema(this: { getClient(): any }, name: string) {
186+
return this.getClient()
187+
.meta.getItem('object', name)
188+
.then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res));
189+
},
190+
getClient: () => ({
191+
meta: {
192+
getItems: (type: string, options?: { packageId?: string }) => {
193+
calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type);
194+
const all = type === 'object' ? ALL : type === 'view' ? VIEWS : type === 'app' ? APPS : [];
195+
const items = options?.packageId ? all.filter((o: any) => o._packageId === options.packageId) : all;
196+
return Promise.resolve({ type, items: structuredClone(items) });
197+
},
198+
getItem: (type: string, name: string) => {
199+
calls.push(`${type}/${name}`);
200+
const item = (type === 'object' ? ALL : []).find((o) => o.name === name);
201+
return item ? Promise.resolve({ item: structuredClone(item) }) : Promise.reject(new Error('404'));
202+
},
203+
},
204+
}),
205+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
206+
}
207+
208+
/** What `AppContent` reads on the route: each scope object with the list views it composed. */
209+
function ScopeProbe() {
210+
useAppObjectScopePrefetch();
211+
const { apps, readObjectScope } = useMetadata();
212+
const { pathname, search } = useLocation();
213+
const app = apps.find((a: any) => a?.name === 'crm');
214+
const read = app && readObjectScope ? readAppObjectScope(readObjectScope, app, pathname.split('/').slice(3).join('/'), search) : undefined;
215+
const objects: any[] = read?.scope?.objects ?? [];
216+
const text = objects
217+
.map((o) => `${o.name}[${Object.keys(o.listViews ?? {}).join('+')}]`)
218+
.sort()
219+
.join(',');
220+
return <div data-testid="scope">{text}</div>;
221+
}
222+
223+
function mountBundled(url: string) {
224+
sessionStorage.clear();
225+
const calls: string[] = [];
226+
const view = render(
227+
<MetadataProvider adapter={makeBundledAdapter(calls)}>
228+
<MemoryRouter initialEntries={[url]}>
229+
<ScopeProbe />
230+
</MemoryRouter>
231+
</MetadataProvider>,
232+
);
233+
return { calls, scope: () => view.getByTestId('scope').textContent };
234+
}
235+
236+
const reads = (calls: string[]) => calls.filter((c) => c !== 'app');
237+
238+
it('a lookup target outside the bundle is asked by name without views: no /meta/view list, and the route object keeps its views', async () => {
239+
const { calls, scope } = mountBundled('/apps/crm/crm_account');
240+
await waitFor(() => expect(scope()).toContain('sys_user['));
241+
await new Promise((resolve) => setTimeout(resolve, 50));
242+
expect(reads(calls)).toEqual([`object?package=${CRM}`, 'object/sys_user']);
243+
expect(scope()).toBe('crm_account[crm_account.all],crm_contact[crm_contact.all],sys_user[]');
244+
});
245+
246+
it('CONTROL: a lookup target the bundle holds asks nothing more and keeps its views', async () => {
247+
const { calls, scope } = mountBundled('/apps/crm/crm_contact');
248+
await waitFor(() => expect(scope()).toContain('crm_contact['));
249+
await new Promise((resolve) => setTimeout(resolve, 50));
250+
expect(reads(calls)).toEqual([`object?package=${CRM}`]);
251+
expect(scope()).toBe('crm_account[crm_account.all],crm_contact[crm_contact.all]');
252+
});
253+
});

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
* the chrome around it read the app's object scope instead
88
* (`MetadataContextValue.readObjectScope`): the package's objects, plus by
99
* name the route's object and its lookup targets when the package does not
10-
* hold them, the objects the app's navigation and area entries require
11-
* (`requiresObject`), and the navigation's objects while the command palette
12-
* is open.
10+
* hold them (the lookup targets without views, objectstack#29551), the objects
11+
* the app's navigation and area entries require (`requiresObject`), and the
12+
* navigation's objects while the command palette is open.
1313
*
1414
* A page (`page/:pageName`, objectui#12980) reads only the objects its page
1515
* definition binds ({@link pageObjectNames}), by name, beside the app's
@@ -565,13 +565,14 @@ function readObjectRouteNames(
565565
const routeDef = objectName ? first.objects.find((o: any) => o?.name === objectName) : undefined;
566566
const lookups = routeDef ? lookupTargetNames(routeDef) : [];
567567
if (lookups.length === 0) return { scope: first, ready, ...routeOutcome };
568-
if (packageId) return { scope: read([...names, ...lookups]), ready, ...routeOutcome };
569-
// In an app with no package the lookup targets are asked without views: a
570-
// lookup cell links and names its target and reads none of its views, and a
571-
// target outside the app bundle (`sys_user`) composed with them read the
572-
// whole `/meta/view` list. They join the names-only scope's objects as they
573-
// land.
574-
(readObjectScope as ReadNamesWithoutViews)({ names: lookups, views: false });
568+
// The lookup targets are asked without views, through the same scope as the
569+
// route's names: a lookup cell links and names its target and reads none of
570+
// its views, and a target outside the app bundle (`sys_user`) composed with
571+
// them read the whole `/meta/view` list (objectstack#29551). They join the
572+
// scope's objects as they land; the route's names keep their views.
573+
const readWithoutViews = readObjectScope as ReadNamesWithoutViews;
574+
if (packageId) readWithoutViews({ packageId, names: lookups, views: false });
575+
else readWithoutViews({ names: lookups, views: false });
575576
return { scope: first, ready, ...routeOutcome };
576577
}
577578

0 commit comments

Comments
 (0)