Skip to content

Commit 706ac52

Browse files
perf(console): answer an app page's views, page and dashboard reads from the app bundle first (#28754)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNiiAtyW6uY3eTDyWYkBLff)_ Part of #28645 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:进入应用后,页面、仪表盘名称和对象视图优先从应用元数据包(bundle)读取;控制台在根部只进入一次 bundle;“对象是否存在”的检查不再拉视图列表。 - 为什么改:这是 #28645 的第一步。读者不再依赖整张 page/dashboard 列表后,下一步(PR 2,#28680)才能去掉导航同步的这两次列表读取。 - 风险与回滚:风险在对象视图的组合,bundle 外的对象仍按 `/meta/view` 列表组合;回滚即还原本 PR,无数据或协议变更。 - 先看:`ui/packages/app-shell/src/providers/MetadataProvider.tsx`(`viewsForObjects`、`peekBundle`)、`ui/packages/app-shell/src/console/appObjectScope.ts`(`enterAppBundle`)、`ui/packages/app-shell/src/views/PageView.tsx`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx`,预期 17 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes The app metadata bundle (#27627) answers by-name reads, but the console's readers looked names up in whole lists. This PR moves those readers to the bundle first: - **`MetadataProvider`** stops reading `/meta/view` at mount (`EAGER_TYPES = ['app']`). A route's object scope composes each object the bundle holds with the bundle's views. It reads the `/meta/view` list only when a name it asks is outside the bundle, and only for the objects the bundle does not hold. A list already in hand keeps answering while a bundle loads, so a settled scope never unsettles. The whole registry (`objects`) still reads the list beside it. - **Bundle-first by-name reads** (`providers/bundledReads.ts`): `PageView`, `AppContent`'s page scope, `AppHeader`'s phone title, `useRecentItemLabel` and `useNavTargetLabel` read a page or dashboard from the bundle, prefer-local by owner package. A miss falls back to the list as before. - **One bundle entry, at the console root**: `useAppObjectScopePrefetch` enters the app's bundle as soon as the app list names the app. `AppContent` reuses that entry (`enterAppBundle`) instead of revalidating it. Leaving the app ends the entry. - **`useObjectPresence`** asks its object with a package-local `views: false` and reads no views. - **`useServedViewItems(read)`**: `AppHeader` reads views only on an object's view route. - **`@object-ui/data-objectstack`**: new `ObjectStackAdapter.peekAppBundle()` and the exported types `AppBundlePeek`, `AppBundleItems`, `AppBundleEntry`, with TSDoc. This is the Clause-② widening. `NavigationSyncEffect` (`useNavigationSync.ts`) is untouched. It still reads `/meta/page` and `/meta/dashboard`, which PR 2 removes (#28680, maintainer ruling A). ## Tests - New `MetadataProvider.bundleFirstReads-28645.test.tsx` (17 cases) and `appBundlePeek-28645.test.ts` (5 cases). `appObjectScope-12811.test.tsx` adds the root bundle entry and its reuse through the console's adapter wrapper. - Updated pins that relied on the eager view read: `objectScope-12811`, `namesOnlyScope-12980`, `oneMetadataRead-13044`, `switchDropsAdapter-13044`, `ConnectedShell.sessionGate`. - Each new rule was ablated and each one turned its pin red: eager view, bundle views, `views: false`, the pending-before-enter rule, bundled objects keeping bundle views, and the root entry. ## Acceptance notes Measured on a production console build (`pnpm build` + `pnpm console:build`, `pnpm dev -- --fresh`), `showcase_app`, admin, API calls of a warm reload, two runs each. Base `2678600d`, head of this PR: | Route | Base | PR 1 | Difference | |---|---|---|---| | `page/showcase_start_here` | 26 | 26 | `/meta/view` −1, `/api/v1/packages` +1 | | `dashboard/showcase_ops_dashboard` | 35 | 35 | none | | `showcase_task` (object list) | 33 | 33 | none | Expected with PR 2 (NavigationSyncEffect stops reading the lists): page 26 → 24, dashboard 35 → 33. That is `/meta/page` and `/meta/dashboard` −1 each, plus this PR's view saving on the page route. This PR ships at net zero because PR 2 needs these readers off the lists. Deviations: - **`/api/v1/packages` +1 on the page route.** `PageView` now draws on the bundle about 250 ms before the page list would land. Its Studio design gate (`useStudioDesignGate`) therefore asks `/packages` outside the in-flight window the sidebar's gates share. `packages-io.ts` forbids a reuse window, so this PR does not add one. - **The dashboard route keeps `/meta/view`.** It reads the whole object registry (`objects`), as on main, and the bundle cannot spare that read without changing the dashboard route's whole-registry rule. - **The object list route keeps `/meta/view`.** `showcase_task`'s lookup target `sys_user` is outside the bundle, so its scope reads the list for the objects the bundle lacks. A reader skips views only where its output is the same without them (`useObjectPresence`). - File surface past the first claim: `app-bundle.ts`, the new `bundledReads.ts`, `appObjectScope.ts` and `useObjectPresence.ts` (claim amendment 6559809254). `AppContent`'s page route no longer waits for the page list bucket. `RecordDetailView` is unchanged, because `usePageAssignment` still reads the page list on record routes. - A miss falls back to the list read rather than a by-name read, which keeps prefer-local resolution. ## Verification <details><summary>Derived gates (35)</summary> Run at `23fefe55` (the last commit after it only replaces a module variable in `appObjectScope.ts`; `check-sdui-manifest` re-ran at the final head, exit 0). `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> <details><summary>Tests and type-check</summary> - 427 test files naming the touched modules (app-shell, data-objectstack, console), 6 locked batches at `--maxWorkers=2`: 427 passed, 3527 tests. The full app-shell suite (~71 min) is left to CI's Test UI shards. - `pnpm turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/data-objectstack`: 66/66 tasks, exit 0. - `node scripts/check-sdui-manifest.mjs`: exit 0. - Merged `origin/main` at `e735b79e` with no overlap on touched files and no parallel fix found. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (run as `eslint --quiet` over the changed ui files: 0 errors; the whole-workspace lint is left to CI) - [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. (except the 维护者速读 block the dispatch rules require) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e62dcc5 commit 706ac52

21 files changed

Lines changed: 1281 additions & 59 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/data-objectstack': patch
4+
---
5+
6+
perf(console): an app page reads its views, its page and its nav dashboards from the app bundle, and reads the `/meta/view`, `/meta/page` and `/meta/dashboard` lists only for what the bundle does not hold
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
Since the app metadata bundle landed, entering an app downloads the app, its objects and their views, pages and dashboards in one request. The console still read the per-type lists beside it. Now:
12+
13+
- **`view` is no longer read at mount.** An object the bundle holds is composed with the bundle's views. A route's object scope reads the `/meta/view` list only when a name it asks is outside the bundle, and then only for the objects the bundle does not hold; such a scope waits for the list before it answers. The whole object registry still reads the list beside it.
14+
- **The console enters the app's bundle once, at its root**, as soon as the app list names the app, and `AppContent` reuses that entry instead of asking the bundle again. Leaving the app ends the entry, so coming back revalidates it.
15+
- **Whether an object exists** (the presence check of the bell and activity surfaces) is asked without views, so it never reads the `/meta/view` list.
16+
- **The page a page route opens** (`/apps/APP/page/NAME`), the phone bar's page or dashboard title, a dashboard nav entry's label and a recent page's label are read from the bundle first. A name the bundle does not hold, or a same-named item another package owns, is read from the list as before, prefer-local resolution included. The page route no longer waits for the page list to load.
17+
- **The app bar reads views only on an object's view route**, the one title that labels a view.
18+
- **New in `@object-ui/data-objectstack`:** `ObjectStackAdapter.peekAppBundle()` reads the active app bundle without waiting (`none` before an app is entered, `pending` while its first answer is on the wire, `ready` with its items), and the types `AppBundlePeek`, `AppBundleItems` and `AppBundleEntry` are exported. A stale bundle is revalidated with `If-None-Match` and keeps answering from what it holds until the revalidation lands.
19+
20+
Navigation sync still reads the page and dashboard lists after the first paint; that read is not changed here.

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordS
2020
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
2121
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
2222
import { useMetadata } from '../providers/MetadataProvider.js';
23+
import { useBundledReads } from '../providers/bundledReads.js';
2324
import { useAdapter } from '../providers/AdapterProvider.js';
2425
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
2526
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js';
@@ -67,7 +68,7 @@ import {
6768
} from '../hooks/useNavMemberGates.js';
6869
import { resolveSetupAppPath } from './ConsoleShell.js';
6970
import { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js';
70-
import { readAppObjectScope } from './appObjectScope.js';
71+
import { enterAppBundle, readAppObjectScope } from './appObjectScope.js';
7172
import { preferLocal } from '../utils/preferLocal.js';
7273
import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js';
7374

@@ -318,6 +319,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
318319
// (objectui#12811). It is read below only on a route that needs all of it.
319320
const metadata = useMetadata();
320321
const { apps, loading: metadataLoading, ensureType, getTypeStatus, error: metadataError, refresh: refreshMetadata, readObjectScope } = metadata;
322+
// objectstack#28645 — what the app bundle answers in render (null outside a
323+
// `MetadataProvider`): the page a page route opens is read there first.
324+
const bundledReads = useBundledReads();
321325
// objectui#7373 — where this file's two "you cannot be here" exits land. Both
322326
// sit BELOW the readiness gate further down (`metadataLoading` &c), so the
323327
// list they resolve against has settled; an app list that failed to load is
@@ -395,10 +399,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
395399
// Only the app the URL names: a pseudo-route's fallback app is not entered.
396400
// A host adapter without the bundle keeps every by-name read.
397401
const bundleAppName: string | undefined = typeof matchedApp?.name === 'string' ? matchedApp.name : undefined;
402+
// objectstack#28645 — the console root entered it first (`enterAppBundle`).
398403
useEffect(() => {
399-
const load = dataSource?.loadAppBundle;
400-
if (!bundleAppName || typeof load !== 'function') return;
401-
void load.call(dataSource, bundleAppName);
404+
enterAppBundle(dataSource, bundleAppName);
402405
}, [dataSource, bundleAppName]);
403406

404407
// objectstack-ai/objectstack#26183 — a URL that reached the app through its
@@ -456,6 +459,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
456459
location.search,
457460
hostRoutePaths,
458461
(pageName) => {
462+
// objectstack#28645 — the bundle's page when it holds this package's;
463+
// the `/meta/page` list, prefer-local, for a page it does not hold.
464+
const bundled = bundledReads?.item('page', pageName, (activeApp as any)?._packageId);
465+
if (bundled?.state === 'hit') return { page: bundled.value, settled: true };
466+
if (bundled?.state === 'pending') return { page: undefined, settled: false };
459467
const status = getTypeStatus?.('page');
460468
return {
461469
page: preferLocal(metadata.pages as any[], pageName, (activeApp as any)?._packageId),
@@ -493,8 +501,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
493501
const allObjects: any[] = objectScope
494502
? scopeWithRegistry ?? objectScope.objects
495503
: readsWholeRegistry ? metadata.objects : NO_OBJECTS;
504+
// objectstack#28645 — a page route reads its page bundle-first (`PageView`,
505+
// the page scope above), so it does not wait for the `/meta/page` list: a
506+
// page outside the bundle is read from the list by those readers, which
507+
// wait for it themselves.
508+
const pageRouteReadsBundle = !!bundledReads && /^page\/[^/]+$/.test(appSubPath);
496509
const routeReadsKey = [
497-
...routeReads.filter((type) => type !== 'object' || readsWholeRegistry),
510+
...routeReads.filter(
511+
(type) => (type !== 'object' || readsWholeRegistry) && !(type === 'page' && pageRouteReadsBundle),
512+
),
498513
...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []),
499514
].join(',');
500515
const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus);

‎ui/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
* - auth pending → zero metadata requests;
1515
* - auth resolved → the eager requests, once each.
1616
*
17-
* objectui#12811: the eager requests are `app` and `view`. The third,
17+
* objectui#12811: the eager requests were `app` and `view`. The third,
1818
* `object`, was the whole object registry, which the root's action runtime
1919
* (`useActionModal`) loaded on every open by reading `objects`; it now loads
2020
* only where a route or an action reads it, and this shell renders neither.
21+
* objectstack#28645: `view` is no longer eager either — it is read beside the
22+
* whole registry, or for objects outside the app bundle — so the one eager
23+
* request is `app`.
2124
*/
2225

2326
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -110,7 +113,7 @@ describe('ConnectedShell session gate (objectui#4042)', () => {
110113
await new Promise((resolve) => setTimeout(resolve, 100));
111114

112115
expect(metaCalls.filter((c) => c === 'object')).toHaveLength(0);
113-
expect(metaCalls.filter((c) => c === 'view')).toHaveLength(1);
116+
expect(metaCalls.filter((c) => c === 'view')).toHaveLength(0);
114117
expect(metaCalls.filter((c) => c === 'app')).toHaveLength(1);
115118
expect(getByTestId('route-content')).toBeTruthy();
116119
});

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
import { describe, it, expect, vi, afterEach } from 'vitest';
2121
import { cleanup, render, waitFor } from '@testing-library/react';
2222
import { MemoryRouter } from 'react-router-dom';
23+
import { AdapterCtx } from '@object-ui/react';
2324
import { MetadataProvider } from '../../providers/MetadataProvider';
24-
import { useAppObjectScopePrefetch } from '../appObjectScope';
25+
import { enterAppBundle, useAppObjectScopePrefetch } from '../appObjectScope';
2526

2627
const CRM = 'app.crm';
2728
const OBJECTS = [
@@ -96,12 +97,45 @@ describe('the console root starts the app object scope (objectui#12811)', () =>
9697
expect(objectCalls(calls)).toEqual([`object?package=${CRM}`, 'object/sys_user']);
9798
});
9899

100+
it('objectstack#28645 — enters the app\'s metadata bundle as it starts the scope, once the app list names the app', async () => {
101+
sessionStorage.clear();
102+
const calls: string[] = [];
103+
const entered: string[] = [];
104+
const adapter = Object.assign(makeAdapter(calls, APPS) as object, {
105+
loadAppBundle: (app: string) => { entered.push(app); return Promise.resolve(); },
106+
}) as Parameters<typeof MetadataProvider>[0]['adapter'];
107+
render(
108+
<AdapterCtx.Provider value={adapter as any}>
109+
<MetadataProvider adapter={adapter}>
110+
<MemoryRouter initialEntries={['/apps/crm/crm_account']}>
111+
<Prefetch />
112+
</MemoryRouter>
113+
</MetadataProvider>
114+
</AdapterCtx.Provider>,
115+
);
116+
await waitFor(() => expect(entered).toEqual(['crm']));
117+
expect(calls).toContain(`object?package=${CRM}`);
118+
// `AppContent` reuses the root's entry rather than asking the bundle again;
119+
// another app, or the app after the root has left it, is entered.
120+
enterAppBundle(adapter, 'crm');
121+
// The console hands `AppContent` a wrapper of the same adapter.
122+
const wrapper = { loadAppBundle: (app: string) => (adapter as any).loadAppBundle(app) };
123+
enterAppBundle(wrapper, 'crm');
124+
expect(entered).toEqual(['crm']);
125+
enterAppBundle(adapter, 'hr');
126+
expect(entered).toEqual(['crm', 'hr']);
127+
cleanup();
128+
enterAppBundle(adapter, 'crm');
129+
expect(entered).toEqual(['crm', 'hr', 'crm']);
130+
});
131+
99132
it('CONTROL: outside an app, on a record form overlay, or for an app without a package id, it asks nothing', async () => {
100133
const outside = mount('/home', APPS);
101134
const overlay = mount('/apps/crm/crm_account?form=new', APPS);
102135
const noPackage = mount('/apps/crm/crm_account', [{ name: 'crm', navigation: [] }]);
103-
await waitFor(() => expect(overlay).toContain('view'));
104-
await waitFor(() => expect(noPackage).toContain('view'));
136+
// objectstack#28645 — the provider's one eager read is the app list.
137+
await waitFor(() => expect(overlay).toContain('app'));
138+
await waitFor(() => expect(noPackage).toContain('app'));
105139
await new Promise((resolve) => setTimeout(resolve, 50));
106140
expect(objectCalls(outside)).toEqual([]);
107141
expect(objectCalls(overlay)).toEqual([]);

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@
5858
* permission read rather than after it, as the whole list used to.
5959
*/
6060

61+
import { useEffect } from 'react';
6162
import { matchRoutes, useLocation } from 'react-router-dom';
62-
import { isRecordPageOf, useMetadata, type MetadataContextValue } from '@object-ui/react';
63+
import { isRecordPageOf, useAdapter, useMetadata, type MetadataContextValue } from '@object-ui/react';
6364
import { defaultRecordPageObject } from '@objectstack/spec/ui';
6465
import { APP_ROUTE_METADATA_READS } from './appRouteMetadataReads.js';
6566
import { matchAppBySegment } from '../utils/appRoute.js';
@@ -437,17 +438,58 @@ export function readAppObjectScope(
437438
};
438439
}
439440

441+
/** An adapter that loads app metadata bundles (`ObjectStackAdapter.loadAppBundle`). */
442+
type BundleHost = { loadAppBundle?: (appName: string) => unknown };
443+
444+
/**
445+
* objectstack#28645 — the app the console root entered, while it stays
446+
* entered (one at a time: the root enters the app its route names). Kept by name, not by adapter: below the root, `AppContent` reads a
447+
* wrapper of the same adapter (the console's approval record reads), so the
448+
* adapter's identity does not say whether the root entered it.
449+
*/
450+
const rootEnteredApps = new Set<string>();
451+
452+
/**
453+
* Enter `appName`'s metadata bundle (objectstack#27627), unless the console
454+
* root already entered it ({@link useAppObjectScopePrefetch}): `AppContent`
455+
* mounts below the session's permission read, and entering again there would
456+
* ask the bundle a second time. A host adapter without bundles is left alone.
457+
*/
458+
export function enterAppBundle(dataSource: unknown, appName: string | undefined): void {
459+
const host = dataSource as BundleHost | null | undefined;
460+
const load = host?.loadAppBundle;
461+
if (!host || !appName || typeof load !== 'function') return;
462+
if (rootEnteredApps.has(appName)) return;
463+
void load.call(host, appName);
464+
}
465+
440466
/**
441467
* The console root's half: on an app route, start the app's object scope as
442468
* soon as the app list names the app, before `AppContent` mounts (it mounts
443-
* below the session's permission read). It reads nothing else and renders
444-
* nothing; `AppContent` reads the same scope entry when it renders.
469+
* below the session's permission read). It renders nothing; `AppContent` reads
470+
* the same scope entry when it renders.
471+
*
472+
* objectstack#28645 — it enters the app's metadata bundle at the same moment,
473+
* once, and `AppContent` reuses that entry ({@link enterAppBundle}), so the
474+
* scope composes its views from the bundle instead of reading the
475+
* `/meta/view` list before the app was entered. Leaving the app ends the
476+
* entry, so coming back revalidates the bundle as entering it always has.
445477
*/
446478
export function useAppObjectScopePrefetch(): void {
447479
const { apps, readObjectScope } = useMetadata();
480+
const dataSource = useAdapter() as BundleHost | null;
448481
const { pathname, search } = useLocation();
449482
const segments = pathname.split('/').filter(Boolean);
450-
if (segments[0] !== 'apps' || !segments[1] || !readObjectScope) return;
451-
const app = matchAppBySegment(apps, segments[1]);
452-
if (app) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search);
483+
const app = segments[0] === 'apps' && segments[1] ? matchAppBySegment(apps, segments[1]) : undefined;
484+
const bundleAppName = typeof app?.name === 'string' && app.name ? app.name : undefined;
485+
useEffect(() => {
486+
const load = dataSource?.loadAppBundle;
487+
if (!dataSource || !bundleAppName || typeof load !== 'function') return;
488+
void load.call(dataSource, bundleAppName);
489+
rootEnteredApps.add(bundleAppName);
490+
return () => {
491+
rootEnteredApps.delete(bundleAppName);
492+
};
493+
}, [dataSource, bundleAppName]);
494+
if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search);
453495
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { resolveViewId } from '@object-ui/core';
7070
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
7171
import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout';
7272
import { useMetadata } from '../providers/MetadataProvider.js';
73+
import { bundledFirst, useBundledReads } from '../providers/bundledReads.js';
7374

7475
/**
7576
* A metadata label (plain string or inline locale map) as display text, or
@@ -139,6 +140,7 @@ function listViewsOf(def: LabelledRecord | undefined): Record<string, LabelledRe
139140

140141
export function useNavTargetLabel(): NavTargetLabelResolver {
141142
const metadata = useMetadata();
143+
const bundledReads = useBundledReads();
142144
const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel();
143145
const { language, t } = useObjectTranslation();
144146
const drafts = useContext(DraftTargetLabels);
@@ -159,7 +161,9 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
159161
return nonEmpty(viewLabel(target.objectName, target.viewName, text ?? ''));
160162
}
161163
case 'dashboard': {
162-
const def = findByName(metadata.dashboards, target.dashboardName);
164+
// objectstack#28645 — the app bundle holds every dashboard its
165+
// navigation names; the list is read only for one it does not hold.
166+
const def = bundledFirst(bundledReads, 'dashboard', target.dashboardName, () => metadata.dashboards);
163167
const text =
164168
metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language);
165169
return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' }));
@@ -170,6 +174,6 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
170174
return undefined;
171175
}
172176
},
173-
[metadata, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
177+
[metadata, bundledReads, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
174178
);
175179
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,14 @@ export function useObjectPresence(name: string): ObjectPresenceReading {
107107
const { app } = useExpressionContext();
108108
const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined;
109109
const inApp = typeof app?.name === 'string' && app.name !== '';
110+
//
111+
// objectstack#28645 — it asks with `views: false` (the provider's hand-off,
112+
// `MetadataProvider`): whether an object is there reads no views, so this
113+
// question never reads the `/meta/view` list.
110114
if ((packageId || inApp) && readObjectScope && getTypeStatus?.('object') === 'idle') {
111-
const scope = readObjectScope(packageId ? { packageId, names: [name] } : { names: [name] });
115+
type ReadScope = NonNullable<typeof readObjectScope>;
116+
const readNames = readObjectScope as (scope: Parameters<ReadScope>[0] & { views: false }) => ReturnType<ReadScope>;
117+
const scope = readNames(packageId ? { packageId, names: [name], views: false } : { names: [name], views: false });
112118
const presence: ObjectPresence = !scope.settled
113119
? 'unknown'
114120
: scope.objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === name)

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { useMemo } from 'react';
6161
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
6262
import type { RecentItem } from '../context/RecentItemsProvider.js';
6363
import { useMetadata } from '../providers/MetadataProvider.js';
64+
import { bundledFirst, useBundledReads } from '../providers/bundledReads.js';
6465
import { findByName, metadataText, useNavTargetLabel, type LabelledRecord } from './useNavTargetLabel.js';
6566

6667
/** Answers the display text of a recent entry, in the current language. */
@@ -80,6 +81,7 @@ export function useRecentItemLabel(
8081
const { packages } = sources;
8182
const targetLabel = useNavTargetLabel();
8283
const metadata = useMetadata();
84+
const bundledReads = useBundledReads();
8385
const { objectPluralLabel, pageLabel, reportLabel } = useObjectLabel();
8486
const { language } = useObjectTranslation();
8587

@@ -102,7 +104,7 @@ export function useRecentItemLabel(
102104
case 'dashboard':
103105
return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name;
104106
case 'page': {
105-
const text = metadataText(findByName(metadata.pages, item.name)?.label, language);
107+
const text = metadataText(bundledFirst(bundledReads, 'page', item.name, () => metadata.pages)?.label, language);
106108
return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name;
107109
}
108110
case 'report': {
@@ -115,6 +117,6 @@ export function useRecentItemLabel(
115117
return item.label;
116118
}
117119
},
118-
[targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language, packages],
120+
[targetLabel, metadata, bundledReads, objectPluralLabel, pageLabel, reportLabel, language, packages],
119121
);
120122
}

0 commit comments

Comments
 (0)