Skip to content

Commit a644163

Browse files
perf(app-shell): the in-app sidebar, the palette and recent rows name their items by name instead of loading whole metadata lists (#29413)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbMtX15PqZB5jGttGLtxEfb2)_ Fixes #29235 Clause-②: no Metadata-protocol: none Claim: 6575822841 · amendment: 6576916626 ## 维护者速读 - 改了什么:应用内侧边栏与命令面板给「没写 label 的导航项」取名时,改为按名字读那个对象/仪表盘,不再下载整张对象注册表;首页等处「最近访问」里的仪表盘、页面、报表行也改为按名字读单条,不再下载整张列表。 - 为什么改:一个导航项不写 label(规范允许,AI 生成的应用常见)时,进应用的每个页面都为取两个名字下载全部对象定义(app-crm 上 551 KB);最近访问里有一条仪表盘或页面,首页就下载整张仪表盘/页面列表。 - 风险与回滚:读法从「一次整表」变成「按名读单条」;应用内按名读由应用包(bundle)直接回答,不发请求。整表已被别处加载时一律直接用整表。显示的名字逐字不变(中英文均实测)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/hooks/useObjectPresence.ts`(新的 `useReadItemsByName`)、`ui/packages/app-shell/src/hooks/useNavTargetLabel.ts`(仪表盘按名读)、`ui/packages/app-shell/src/chrome/CommandPalette.tsx`(面板取名改 byName)。 - 两分钟验证:打开一个导航项没写 label 的应用里的页面,开发者工具 Network 过滤 `meta/object`:不再有不带名字的 `GET /api/v1/meta/object`;侧边栏显示的名字与改前一致。 - 需要你做的:无,席位落地。 ## What changes Two label readers still downloaded a whole metadata list to name one row. Measured with CDP initiator stacks on a production Console (app-crm): | Reader | Before | Now | |---|---|---| | `UnifiedSidebar` (the in-app sidebar): an entry with no label of its own | `useNavTargetLabel()` read `metadata.objects`: the whole object registry and the `/meta/view` list | `useNavTargetLabel({ byName: true })`: the entry's object by name (the app bundle answers it, no request); a dashboard entry the bundle does not hold, by name | | `CommandPalette` (mounted closed in every app; it names each navigation entry on every render to build its lists) | `useNavTargetLabel()`, the same whole-list read, hidden behind the sidebar's by the provider cache | `useNavTargetLabel(RECENT_ROW_LABELS)`, the `{ byName: true }` constant the palette's recent rows already use | | `useRecentItemLabel`: a recent dashboard row | `metadata.dashboards` (through `useNavTargetLabel`'s `bundledFirst` fallback) | the dashboard by name | | `useRecentItemLabel`: a recent page row / report row | `metadata.pages` (behind `bundledFirst`) / `metadata.reports` | the page / report by name | `useReadItemsByName` (in `hooks/useObjectPresence.ts`, beside `readObjectsByName`) is the one helper for the non-object types. It asks through the provider's existing by-name read (`getItem`), so no provider or package export is added (`@object-ui/app-shell` is private). While nothing has loaded the type's list, each name is asked once and the reader re-renders as it lands. Once the list is loaded or loading, it reads the list and asks nothing. A hand-rolled context with no status or no `getItem` reads its list, as before. A name that answered an item is asked again after the provider's value changes (the provider's by-name cache answers it without a request, unless an invalidation dropped it, so a rename shows). A name that answered nothing is not asked again while the reader is mounted. `useNavTargetLabel({ byName: true })` now also reads a dashboard target the bundle does not hold by name. The other callers (Studio, search page, inspectors, `nav:menu`) are unchanged. `views/nav-menu-renderer.tsx` is the `nav:menu` page block, not the in-app sidebar, so it is untouched. ## Tests - `layout/__tests__/UnifiedSidebar.labelReadsByName-29235.test.tsx`: the real sidebar over the real `MetadataProvider` and a recording adapter. Unlabelled object and dashboard entries read `object/NAME` and `dashboard/NAME`, never the `object`, `view` or `dashboard` list. Labels pinned in en and zh-CN (inline locale maps). - `chrome/CommandPalette.navLabelReadsByName-29235.test.tsx`: the palette closed inside an app reads each entry's object by name and no list; opened, the entries show their objects' labels. - `hooks/__tests__/useRecentItemLabel.readsByName-29235.test.tsx`: dashboard, page and report rows ask their own item, never the list. Labels pinned in en, and in zh-CN through an inline locale map and a translation-bundle key. The machine name shows until the item lands, and for an item the server does not have (asked once). CONTROL: with the lists loaded, the rows read them and ask no item. ## Acceptance notes - **Claim amendment (6576916626):** `chrome/CommandPalette.tsx`, at the `:239` hook call only. The production re-measure showed it was the reader left in-app once the sidebar asked by name. - **Not changed, found while measuring:** - Inside a package-less app (one created in the console), `useNavMemberGates` reads its names-only scope with views, which reads the whole `/meta/view` list (7,014 B on app-crm). Outside an app, #29121 already asks it with `views: false`. - The same app's object routes read the whole object registry from `AppContent`: a package-less app has no object scope (`appObjectScope.ts`). So the in-app saving shows on its page routes, and on every route of a packaged app with unlabelled entries. - A page route for a page the app bundle does not hold reads the `/meta/page` list (`AppContent`'s page scope, prefer-local). - `openPathObjectReaders-12811`'s row for `useNavTargetLabel.ts` still reads "off" with a reason naming Home; the census stays green, and its prose is not updated here (outside the claim's surface). - A by-name read answers the item the server resolves for that name. The list read took the first item with that name. They differ only if two packages ship a same-named dashboard, page or report. ## Verification <details><summary>Production Console, app-crm (<code>objectstack dev --fresh</code>), before (base <code>b1779200</code>) and after (head <code>db585d60</code>; later commits only move the by-name reader's asked set into a ref and merge main)</summary> Setup: an app `ops_app` saved through `PUT /api/v1/meta/app/ops_app` with three unlabelled entries (`crm_account`, `crm_contact`, `pipeline_dashboard`). Recent rows come from visiting a dashboard and a page. Playwright logs in, reloads each page with the cache disabled, and logs every `/api/` request with its CDP initiator stack. In-app page route `/apps/ops_app/page/crm_welcome`, en: - Before: 29 requests, 889,029 B, including `GET /api/v1/meta/object` (551,834 B) and `/meta/view` (7,014 B). The initiator is `get objects` from the `useNavTargetLabel` chunk, called from `ui-layout`'s `NavigationRenderer`. - After: 28 requests, 337,195 B. No `GET /api/v1/meta/object` without a name. `crm_account` and `crm_contact` are answered by the app bundle, with no request. - With only the sidebar changed, the object list was still read. Its initiator was `CommandPalette`'s `navLabel`, which led to the claim amendment. Home `/home`, with a recent dashboard row and a recent page row, en: - Before: 28 requests, 302,598 B, including `GET /api/v1/meta/dashboard` (5,596 B) and `/meta/page` (15,463 B). - After: 28 requests, 284,915 B, including `GET /api/v1/meta/dashboard/pipeline_dashboard` (3,054 B) and `/meta/page/crm_welcome` (1,044 B). The text shown is identical before and after: - Sidebar, en: `Account`, `Contact`, `Pipeline Dashboard`. - Sidebar, zh-CN: `客户`, `联系人`, `销售漏斗`. - Home recent rows, en: `CRM Welcome`, `Pipeline Dashboard`. - Home recent rows, zh-CN: `欢迎页`, `销售漏斗仪表盘`. </details> <details><summary>Ablations (each through <code>scripts/ablation-replace.mjs</code>, restored with <code>git diff HEAD</code> empty)</summary> - A1: the sidebar's `{ byName: true }` removed. Both sidebar cases red. - A2: the dashboard target reads `metadata.dashboards` again. Both sidebar cases and three recents cases red. - A3: a page row reads `metadata.pages`. Three recents cases red. - A4: a report row reads `metadata.reports`. Three recents cases red. - A5: an absent name asked again after each provider change. The "asked once" case red (re-run on the final head). - A6: the palette's `navLabel` resolver without `byName`. Both palette cases red. </details> <details><summary>Local runs</summary> - 220 test files naming the touched modules and their surfaces (`useNavTargetLabel`, `useRecentItemLabel`, `UnifiedSidebar`, `useObjectPresence`, `readObjectsByName`, `HomePage`, `RecentApps`, `CommandPalette`, `BuilderLanding`, `ConsoleLayout`, `HomeRail`), at `a3eaa1ec` under `os-verify-lock`, `--maxWorkers=2`, four batches: 220 files and 1,797 tests passed. After merging main (#29327 edits `CommandPalette.tsx`), the 77 `CommandPalette` and new-test files passed again at `fa2a8da5` (642 tests). CI's Test UI runs the rest of app-shell. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 tasks. - `eslint --no-inline-config` on the changed files: 0 errors. The three hooks have no warnings. - Derived gates (`dispatch-gates --commands`): 34 commands, all exit 0 at `fa2a8da5`, reconciled with `--ran` (34 derived, 34 run, 0 NOT-MEASURED). The head `3dcde8cd` only merges main again (no touched file moved); on it the 35 sidebar, palette and new-test files (165 tests), app-shell type-check (65/65), `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_01QsR2jUpJJCdztFcegijAq7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02bceb7 commit a644163

9 files changed

Lines changed: 564 additions & 13 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
perf(app-shell): the app sidebar and recent rows label their items by name
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Two label readers still downloaded a whole metadata list to name one row:
11+
12+
- Inside an app, a navigation entry with no label of its own read the whole object registry (`GET /api/v1/meta/object`, with the `/meta/view` list it is composed with) on every page whose route had not loaded it: the sidebar named it, and so did the closed command palette, which names every entry to build its lists. Both now resolve labels with `useNavTargetLabel({ byName: true })`: the entry's object by name, which the app bundle answers, and a dashboard entry the bundle does not hold by name too.
13+
- A recent dashboard, page or report row (Home, the command palette, the sidebar's recent list) read its type's whole list. Each row now asks its own item through the provider's by-name read (`useReadItemsByName`, beside `readObjectsByName`).
14+
15+
Once something else has loaded a list, these readers read it and ask nothing more. The labels shown are unchanged, in every language, and an item that has not landed shows its machine name, as before.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29235 — the palette labels an unlabelled navigation entry by
5+
* reading its target by name, never the whole object registry.
6+
*
7+
* Inside an app the console mounts the palette closed, and it names every
8+
* entry of the app's navigation on each render to build its filtered lists.
9+
* An entry with no label of its own is named through `useNavTargetLabel`
10+
* (objectui#9868), which read `useMetadata().objects`: the whole object
11+
* registry and the `/meta/view` list, on every in-app page whose route had
12+
* not loaded them (551 KB on app-crm). Measured on a production Console, it
13+
* was the reader left once the sidebar asked by name. It now asks with
14+
* `{ byName: true }`, as the sidebar does.
15+
*
16+
* Real subjects: `CommandPalette`, `CommandPaletteProvider` and
17+
* `MetadataProvider` over a fake adapter that records each read; the harness
18+
* is `CommandPalette.recentReadsByName-29121`'s.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import * as React from 'react';
23+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
24+
import { render, cleanup, act, screen } from '@testing-library/react';
25+
import { MemoryRouter } from 'react-router-dom';
26+
import { createI18n, I18nProvider } from '@object-ui/i18n';
27+
import { MetadataProvider } from '../providers/MetadataProvider';
28+
29+
vi.mock('@object-ui/auth', async (importOriginal) => ({
30+
...(await importOriginal<Record<string, unknown>>()),
31+
useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
32+
}));
33+
34+
vi.mock('../context/RecentItemsProvider', async (importOriginal) => ({
35+
...(await importOriginal<Record<string, unknown>>()),
36+
useRecentItems: () => ({ recentItems: [], addRecentItem: () => {}, clearRecentItems: () => {} }),
37+
}));
38+
39+
vi.mock('../utils/getIcon', () => ({
40+
getIcon: () =>
41+
function StubIcon(props: { className?: string }) {
42+
return <svg data-testid="stub-icon" className={props.className} />;
43+
},
44+
}));
45+
46+
import { CommandPalette } from './CommandPalette';
47+
import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
48+
49+
// An app written without entry labels, as the spec allows (objectui#9868).
50+
const APP = {
51+
name: 'ops',
52+
label: 'Ops',
53+
navigation: [
54+
{ id: 'nav_accounts', type: 'object', objectName: 'crm_account' },
55+
{ id: 'nav_contacts', type: 'object', objectName: 'crm_contact' },
56+
],
57+
};
58+
59+
const OBJECTS = [
60+
{ name: 'crm_account', label: 'Account', fields: {} },
61+
{ name: 'crm_contact', label: 'Contact', fields: {} },
62+
{ name: 'crm_lead', label: 'Lead', fields: {} },
63+
];
64+
65+
function makeAdapter(calls: string[]) {
66+
return {
67+
clearCache: vi.fn(),
68+
invalidateCache: vi.fn(),
69+
getObjectSchema(this: { getClient(): any }, name: string) {
70+
return this.getClient()
71+
.meta.getItem('object', name)
72+
.then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res));
73+
},
74+
getClient: () => ({
75+
meta: {
76+
getItems: (type: string, options?: { packageId?: string }) => {
77+
calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type);
78+
const items = type === 'object' ? OBJECTS : type === 'app' ? [APP] : [];
79+
return Promise.resolve({ type, items: structuredClone(items) });
80+
},
81+
getItem: (type: string, name: string) => {
82+
calls.push(`${type}/${name}`);
83+
const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name) ?? null;
84+
return Promise.resolve({ item: structuredClone(item) });
85+
},
86+
},
87+
}),
88+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
89+
}
90+
91+
/** The whole-list reads a label could cost: the object registry and its views. */
92+
const listReads = (calls: string[]) => calls.filter((c) => c === 'object' || c === 'view');
93+
94+
function mount(path: string): string[] {
95+
const calls: string[] = [];
96+
render(
97+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
98+
<MemoryRouter initialEntries={[path]}>
99+
<MetadataProvider adapter={makeAdapter(calls)}>
100+
<CommandPaletteProvider>
101+
<CommandPalette apps={[APP]} activeApp={APP} objects={[]} onAppChange={() => {}} />
102+
</CommandPaletteProvider>
103+
</MetadataProvider>
104+
</MemoryRouter>
105+
</I18nProvider>,
106+
);
107+
return calls;
108+
}
109+
110+
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 50)));
111+
112+
beforeEach(() => sessionStorage.clear());
113+
afterEach(() => cleanup());
114+
115+
describe('objectstack#29235 — the palette names an unlabelled entry by name', () => {
116+
it('closed inside an app: each entry’s object is read by name, never the whole list', async () => {
117+
const calls = mount('/apps/ops/page/home');
118+
await settle();
119+
await settle();
120+
expect(listReads(calls)).toEqual([]);
121+
expect(calls.filter((c) => c.startsWith('object/')).sort()).toEqual(['object/crm_account', 'object/crm_contact']);
122+
});
123+
124+
it('opened: the entries show their objects’ labels, as the whole list gave them', async () => {
125+
const calls = mount('/apps/ops/page/home?palette=1');
126+
expect(await screen.findByText('Account')).toBeInTheDocument();
127+
expect(await screen.findByText('Contact')).toBeInTheDocument();
128+
await settle();
129+
expect(listReads(calls)).toEqual([]);
130+
});
131+
});

‎ui/packages/app-shell/src/chrome/CommandPalette.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ function CommandPaletteBody(props: CommandPaletteProps & { gates: NavMemberGates
236236
// such entries through `useNavigationWrites`). A present
237237
// label renders as authored — an inline locale map in the viewer's `language`,
238238
// the locale the sidebar passes (objectui#11299).
239-
const targetLabel = useNavTargetLabel();
239+
const targetLabel = useNavTargetLabel(RECENT_ROW_LABELS);
240240
const navLabel = (item: NavigationItem) =>
241241
resolveNavItemLabel(item, t, targetLabel, language);
242242

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29235 — a recent dashboard, page or report row reads its item by
5+
* name, never its type's whole list.
6+
*
7+
* `useRecentItemLabel` labelled those rows from `metadata.dashboards` (through
8+
* `useNavTargetLabel`), `metadata.pages` and `metadata.reports`, so a Home with
9+
* one such recent row downloaded the whole `/meta/dashboard`, `/meta/page` or
10+
* `/meta/report` list for one label. Each row now asks its own item through
11+
* the provider's by-name read (`useReadItemsByName`), and reads the list only
12+
* once something else has loaded it.
13+
*
14+
* Real subjects: `useRecentItemLabel`, `MetadataProvider` and the i18n
15+
* provider, over a fake adapter that records each read. What the rows print is
16+
* pinned beside the reads: the same text the whole list gave, in en and in
17+
* zh-CN (an inline locale map, and a translation bundle key), and the machine
18+
* name while nothing has landed.
19+
*/
20+
21+
import { describe, it, expect, vi, afterEach } from 'vitest';
22+
import type { ReactNode } from 'react';
23+
import { renderHook, waitFor, cleanup, act } from '@testing-library/react';
24+
import { createI18n, I18nProvider } from '@object-ui/i18n';
25+
import { useMetadata } from '@object-ui/react';
26+
import { MetadataProvider } from '../../providers/MetadataProvider';
27+
import { useRecentItemLabel } from '../useRecentItemLabel';
28+
import type { RecentItem } from '../../context/RecentItemsProvider';
29+
30+
const ITEMS: Record<string, Array<Record<string, unknown>>> = {
31+
dashboard: [
32+
{ name: 'pipeline', label: 'Pipeline' },
33+
{ name: 'other_dashboard', label: 'Other' },
34+
],
35+
page: [
36+
{ name: 'welcome', label: { en: 'Welcome', 'zh-CN': '欢迎' } },
37+
{ name: 'other_page', label: 'Other page' },
38+
],
39+
report: [
40+
{ name: 'q3_results', label: 'Q3 Results' },
41+
{ name: 'other_report', label: 'Other report' },
42+
],
43+
};
44+
45+
function makeAdapter(calls: string[]) {
46+
return {
47+
clearCache: vi.fn(),
48+
invalidateCache: vi.fn(),
49+
getClient: () => ({
50+
meta: {
51+
getItems: (type: string) => {
52+
calls.push(type);
53+
return Promise.resolve({ type, items: structuredClone(ITEMS[type] ?? []) });
54+
},
55+
getItem: (type: string, name: string) => {
56+
calls.push(`${type}/${name}`);
57+
const item = (ITEMS[type] ?? []).find((it) => it.name === name);
58+
if (!item) return Promise.reject(Object.assign(new Error('not found'), { status: 404, code: 'NOT_FOUND' }));
59+
return Promise.resolve({ item: structuredClone(item) });
60+
},
61+
},
62+
}),
63+
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
64+
}
65+
66+
function wrapperFor(calls: string[], language = 'en') {
67+
const i18n = createI18n({
68+
defaultLanguage: language,
69+
detectBrowserLanguage: false,
70+
resources: { 'zh-CN': { showcase: { reports: { q3_results: { label: '第三季度结果' } } } } },
71+
});
72+
const adapter = makeAdapter(calls);
73+
return ({ children }: { children: ReactNode }) => (
74+
<I18nProvider instance={i18n} persistLanguage={false}>
75+
<MetadataProvider adapter={adapter}>{children}</MetadataProvider>
76+
</I18nProvider>
77+
);
78+
}
79+
80+
const at = '2026-11-02T09:00:00.000Z';
81+
const ROWS: RecentItem[] = [
82+
{ id: 'dashboard:pipeline', type: 'dashboard', name: 'pipeline', href: '/d', visitedAt: at },
83+
{ id: 'page:welcome', type: 'page', name: 'welcome', href: '/p', visitedAt: at },
84+
{ id: 'report:q3_results', type: 'report', name: 'q3_results', href: '/r', visitedAt: at },
85+
];
86+
87+
/** The whole-list reads of the three types. */
88+
const listReads = (calls: string[]) => calls.filter((c) => c === 'dashboard' || c === 'page' || c === 'report');
89+
90+
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 50)));
91+
92+
afterEach(() => cleanup());
93+
94+
describe('recent dashboard / page / report rows read their item by name (objectstack#29235)', () => {
95+
it('each row asks its own item, never its type’s list, and prints what the list gave', async () => {
96+
const calls: string[] = [];
97+
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapperFor(calls) });
98+
await waitFor(() => expect(ROWS.map((row) => result.current(row))).toEqual(['Pipeline', 'Welcome', 'Q3 Results']));
99+
await settle();
100+
expect(listReads(calls)).toEqual([]);
101+
expect(calls.filter((c) => c.includes('/')).sort()).toEqual(['dashboard/pipeline', 'page/welcome', 'report/q3_results']);
102+
});
103+
104+
it('zh-CN: an inline locale map and a translation bundle key read as the list gave them', async () => {
105+
const calls: string[] = [];
106+
const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapperFor(calls, 'zh-CN') });
107+
await waitFor(() => expect(ROWS.map((row) => result.current(row))).toEqual(['Pipeline', '欢迎', '第三季度结果']));
108+
expect(listReads(calls)).toEqual([]);
109+
});
110+
111+
it('the machine name until the item lands, and for an item the server does not have (asked once)', async () => {
112+
const calls: string[] = [];
113+
const gone: RecentItem = { id: 'report:gone', type: 'report', name: 'gone', href: '/r', visitedAt: at };
114+
const { result, rerender } = renderHook(() => useRecentItemLabel(), { wrapper: wrapperFor(calls) });
115+
expect(ROWS.map((row) => result.current(row))).toEqual(['pipeline', 'welcome', 'q3_results']);
116+
expect(result.current(gone)).toBe('gone');
117+
await waitFor(() => expect(result.current(ROWS[2])).toBe('Q3 Results'));
118+
await settle();
119+
rerender();
120+
expect(result.current(gone)).toBe('gone');
121+
expect(calls.filter((c) => c === 'report/gone')).toEqual(['report/gone']);
122+
expect(listReads(calls)).toEqual([]);
123+
});
124+
125+
it('CONTROL: once the lists are loaded, the rows read them and ask no item', async () => {
126+
const calls: string[] = [];
127+
const { result } = renderHook(
128+
() => {
129+
const metadata = useMetadata();
130+
return { label: useRecentItemLabel(), metadata };
131+
},
132+
{ wrapper: wrapperFor(calls) },
133+
);
134+
await act(async () => {
135+
await Promise.all(['dashboard', 'page', 'report'].map((type) => result.current.metadata.ensureType(type)));
136+
});
137+
await waitFor(() => expect(ROWS.map((row) => result.current.label(row))).toEqual(['Pipeline', 'Welcome', 'Q3 Results']));
138+
await settle();
139+
expect(listReads(calls).sort()).toEqual(['dashboard', 'page', 'report']);
140+
expect(calls.filter((c) => c.includes('/'))).toEqual([]);
141+
});
142+
});

‎ui/packages/app-shell/src/hooks/useNavTargetLabel.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
7171
import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout';
7272
import { useMetadata } from '../providers/MetadataProvider.js';
7373
import { bundledFirst, useBundledReads } from '../providers/bundledReads.js';
74-
import { readObjectsByName } from './useObjectPresence.js';
74+
import { readObjectsByName, useReadItemsByName } from './useObjectPresence.js';
7575

7676
/**
7777
* A metadata label (plain string or inline locale map) as display text, or
@@ -141,12 +141,14 @@ function listViewsOf(def: LabelledRecord | undefined): Record<string, LabelledRe
141141

142142
/**
143143
* How the resolver reads an object (objectstack#29121). By default from the
144-
* whole `objects` list, as the sidebar and every other caller does. With
144+
* whole `objects` list, as the Studio's and the search page's callers do. With
145145
* `byName`, an `object` or `view` target reads its object by name
146146
* (`readObjectsByName`) while nothing has loaded the whole list, so a surface
147-
* that names a few objects (Home's recent rows) never loads the registry for
148-
* their labels; a `view` target's object is read with its views, which its
149-
* label is.
147+
* that names a few objects (Home's recent rows, the app sidebar) never loads
148+
* the registry for their labels; a `view` target's object is read with its
149+
* views, which its label is. A `dashboard` target the app bundle does not hold
150+
* is read by name the same way (`useReadItemsByName`, objectstack#29235),
151+
* never through the whole `/meta/dashboard` list.
150152
*/
151153
export interface NavTargetLabelOptions {
152154
byName?: boolean;
@@ -155,6 +157,7 @@ export interface NavTargetLabelOptions {
155157
export function useNavTargetLabel(options?: NavTargetLabelOptions): NavTargetLabelResolver {
156158
const metadata = useMetadata();
157159
const byName = options?.byName === true;
160+
const readItemsByName = useReadItemsByName(metadata);
158161
const bundledReads = useBundledReads();
159162
const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel();
160163
const { language, t } = useObjectTranslation();
@@ -180,7 +183,9 @@ export function useNavTargetLabel(options?: NavTargetLabelOptions): NavTargetLab
180183
case 'dashboard': {
181184
// objectstack#28645 — the app bundle holds every dashboard its
182185
// navigation names; the list is read only for one it does not hold.
183-
const def = bundledFirst(bundledReads, 'dashboard', target.dashboardName, () => metadata.dashboards);
186+
const def = bundledFirst(bundledReads, 'dashboard', target.dashboardName, () =>
187+
byName ? readItemsByName('dashboard', [target.dashboardName], () => metadata.dashboards) : metadata.dashboards,
188+
);
184189
const text =
185190
metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language);
186191
return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' }));
@@ -191,6 +196,6 @@ export function useNavTargetLabel(options?: NavTargetLabelOptions): NavTargetLab
191196
return undefined;
192197
}
193198
},
194-
[metadata, byName, bundledReads, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
199+
[metadata, byName, readItemsByName, bundledReads, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
195200
);
196201
}

0 commit comments

Comments
 (0)