Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/28645-app-page-reads-from-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': patch
'@object-ui/data-objectstack': patch
---

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

Clause-②: yes (widening)
Metadata-protocol: none

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:

- **`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.
- **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.
- **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.
- **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.
- **The app bar reads views only on an object's view route**, the one title that labels a view.
- **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.

Navigation sync still reads the page and dashboard lists after the first paint; that read is not changed here.
25 changes: 20 additions & 5 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordS
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
import { useMetadata } from '../providers/MetadataProvider.js';
import { useBundledReads } from '../providers/bundledReads.js';
import { useAdapter } from '../providers/AdapterProvider.js';
import { usePreviewDrafts } from '../preview/PreviewModeContext.js';
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js';
Expand Down Expand Up @@ -67,7 +68,7 @@ import {
} from '../hooks/useNavMemberGates.js';
import { resolveSetupAppPath } from './ConsoleShell.js';
import { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js';
import { readAppObjectScope } from './appObjectScope.js';
import { enterAppBundle, readAppObjectScope } from './appObjectScope.js';
import { preferLocal } from '../utils/preferLocal.js';
import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js';

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

// objectstack-ai/objectstack#26183 — a URL that reached the app through its
Expand Down Expand Up @@ -456,6 +459,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
location.search,
hostRoutePaths,
(pageName) => {
// objectstack#28645 — the bundle's page when it holds this package's;
// the `/meta/page` list, prefer-local, for a page it does not hold.
const bundled = bundledReads?.item('page', pageName, (activeApp as any)?._packageId);
if (bundled?.state === 'hit') return { page: bundled.value, settled: true };
if (bundled?.state === 'pending') return { page: undefined, settled: false };
const status = getTypeStatus?.('page');
return {
page: preferLocal(metadata.pages as any[], pageName, (activeApp as any)?._packageId),
Expand Down Expand Up @@ -493,8 +501,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
const allObjects: any[] = objectScope
? scopeWithRegistry ?? objectScope.objects
: readsWholeRegistry ? metadata.objects : NO_OBJECTS;
// objectstack#28645 — a page route reads its page bundle-first (`PageView`,
// the page scope above), so it does not wait for the `/meta/page` list: a
// page outside the bundle is read from the list by those readers, which
// wait for it themselves.
const pageRouteReadsBundle = !!bundledReads && /^page\/[^/]+$/.test(appSubPath);
const routeReadsKey = [
...routeReads.filter((type) => type !== 'object' || readsWholeRegistry),
...routeReads.filter(
(type) => (type !== 'object' || readsWholeRegistry) && !(type === 'page' && pageRouteReadsBundle),
),
...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []),
].join(',');
const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
* - auth pending → zero metadata requests;
* - auth resolved → the eager requests, once each.
*
* objectui#12811: the eager requests are `app` and `view`. The third,
* objectui#12811: the eager requests were `app` and `view`. The third,
* `object`, was the whole object registry, which the root's action runtime
* (`useActionModal`) loaded on every open by reading `objects`; it now loads
* only where a route or an action reads it, and this shell renders neither.
* objectstack#28645: `view` is no longer eager either — it is read beside the
* whole registry, or for objects outside the app bundle — so the one eager
* request is `app`.
*/

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

expect(metaCalls.filter((c) => c === 'object')).toHaveLength(0);
expect(metaCalls.filter((c) => c === 'view')).toHaveLength(1);
expect(metaCalls.filter((c) => c === 'view')).toHaveLength(0);
expect(metaCalls.filter((c) => c === 'app')).toHaveLength(1);
expect(getByTestId('route-content')).toBeTruthy();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { AdapterCtx } from '@object-ui/react';
import { MetadataProvider } from '../../providers/MetadataProvider';
import { useAppObjectScopePrefetch } from '../appObjectScope';
import { enterAppBundle, useAppObjectScopePrefetch } from '../appObjectScope';

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

it('objectstack#28645 — enters the app\'s metadata bundle as it starts the scope, once the app list names the app', async () => {
sessionStorage.clear();
const calls: string[] = [];
const entered: string[] = [];
const adapter = Object.assign(makeAdapter(calls, APPS) as object, {
loadAppBundle: (app: string) => { entered.push(app); return Promise.resolve(); },
}) as Parameters<typeof MetadataProvider>[0]['adapter'];
render(
<AdapterCtx.Provider value={adapter as any}>
<MetadataProvider adapter={adapter}>
<MemoryRouter initialEntries={['/apps/crm/crm_account']}>
<Prefetch />
</MemoryRouter>
</MetadataProvider>
</AdapterCtx.Provider>,
);
await waitFor(() => expect(entered).toEqual(['crm']));
expect(calls).toContain(`object?package=${CRM}`);
// `AppContent` reuses the root's entry rather than asking the bundle again;
// another app, or the app after the root has left it, is entered.
enterAppBundle(adapter, 'crm');
// The console hands `AppContent` a wrapper of the same adapter.
const wrapper = { loadAppBundle: (app: string) => (adapter as any).loadAppBundle(app) };
enterAppBundle(wrapper, 'crm');
expect(entered).toEqual(['crm']);
enterAppBundle(adapter, 'hr');
expect(entered).toEqual(['crm', 'hr']);
cleanup();
enterAppBundle(adapter, 'crm');
expect(entered).toEqual(['crm', 'hr', 'crm']);
});

it('CONTROL: outside an app, on a record form overlay, or for an app without a package id, it asks nothing', async () => {
const outside = mount('/home', APPS);
const overlay = mount('/apps/crm/crm_account?form=new', APPS);
const noPackage = mount('/apps/crm/crm_account', [{ name: 'crm', navigation: [] }]);
await waitFor(() => expect(overlay).toContain('view'));
await waitFor(() => expect(noPackage).toContain('view'));
// objectstack#28645 — the provider's one eager read is the app list.
await waitFor(() => expect(overlay).toContain('app'));
await waitFor(() => expect(noPackage).toContain('app'));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(objectCalls(outside)).toEqual([]);
expect(objectCalls(overlay)).toEqual([]);
Expand Down
54 changes: 48 additions & 6 deletions ui/packages/app-shell/src/console/appObjectScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@
* permission read rather than after it, as the whole list used to.
*/

import { useEffect } from 'react';
import { matchRoutes, useLocation } from 'react-router-dom';
import { isRecordPageOf, useMetadata, type MetadataContextValue } from '@object-ui/react';
import { isRecordPageOf, useAdapter, useMetadata, type MetadataContextValue } from '@object-ui/react';
import { defaultRecordPageObject } from '@objectstack/spec/ui';
import { APP_ROUTE_METADATA_READS } from './appRouteMetadataReads.js';
import { matchAppBySegment } from '../utils/appRoute.js';
Expand Down Expand Up @@ -437,17 +438,58 @@ export function readAppObjectScope(
};
}

/** An adapter that loads app metadata bundles (`ObjectStackAdapter.loadAppBundle`). */
type BundleHost = { loadAppBundle?: (appName: string) => unknown };

/**
* objectstack#28645 — the app the console root entered, while it stays
* 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
* wrapper of the same adapter (the console's approval record reads), so the
* adapter's identity does not say whether the root entered it.
*/
const rootEnteredApps = new Set<string>();

/**
* Enter `appName`'s metadata bundle (objectstack#27627), unless the console
* root already entered it ({@link useAppObjectScopePrefetch}): `AppContent`
* mounts below the session's permission read, and entering again there would
* ask the bundle a second time. A host adapter without bundles is left alone.
*/
export function enterAppBundle(dataSource: unknown, appName: string | undefined): void {
const host = dataSource as BundleHost | null | undefined;
const load = host?.loadAppBundle;
if (!host || !appName || typeof load !== 'function') return;
if (rootEnteredApps.has(appName)) return;
void load.call(host, appName);
}

/**
* The console root's half: on an app route, start the app's object scope as
* soon as the app list names the app, before `AppContent` mounts (it mounts
* below the session's permission read). It reads nothing else and renders
* nothing; `AppContent` reads the same scope entry when it renders.
* below the session's permission read). It renders nothing; `AppContent` reads
* the same scope entry when it renders.
*
* objectstack#28645 — it enters the app's metadata bundle at the same moment,
* once, and `AppContent` reuses that entry ({@link enterAppBundle}), so the
* scope composes its views from the bundle instead of reading the
* `/meta/view` list before the app was entered. Leaving the app ends the
* entry, so coming back revalidates the bundle as entering it always has.
*/
export function useAppObjectScopePrefetch(): void {
const { apps, readObjectScope } = useMetadata();
const dataSource = useAdapter() as BundleHost | null;
const { pathname, search } = useLocation();
const segments = pathname.split('/').filter(Boolean);
if (segments[0] !== 'apps' || !segments[1] || !readObjectScope) return;
const app = matchAppBySegment(apps, segments[1]);
if (app) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search);
const app = segments[0] === 'apps' && segments[1] ? matchAppBySegment(apps, segments[1]) : undefined;
const bundleAppName = typeof app?.name === 'string' && app.name ? app.name : undefined;
useEffect(() => {
const load = dataSource?.loadAppBundle;
if (!dataSource || !bundleAppName || typeof load !== 'function') return;
void load.call(dataSource, bundleAppName);
rootEnteredApps.add(bundleAppName);
return () => {
rootEnteredApps.delete(bundleAppName);
};
}, [dataSource, bundleAppName]);
if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search);
}
8 changes: 6 additions & 2 deletions ui/packages/app-shell/src/hooks/useNavTargetLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { resolveViewId } from '@object-ui/core';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout';
import { useMetadata } from '../providers/MetadataProvider.js';
import { bundledFirst, useBundledReads } from '../providers/bundledReads.js';

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

export function useNavTargetLabel(): NavTargetLabelResolver {
const metadata = useMetadata();
const bundledReads = useBundledReads();
const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel();
const { language, t } = useObjectTranslation();
const drafts = useContext(DraftTargetLabels);
Expand All @@ -159,7 +161,9 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
return nonEmpty(viewLabel(target.objectName, target.viewName, text ?? ''));
}
case 'dashboard': {
const def = findByName(metadata.dashboards, target.dashboardName);
// objectstack#28645 — the app bundle holds every dashboard its
// navigation names; the list is read only for one it does not hold.
const def = bundledFirst(bundledReads, 'dashboard', target.dashboardName, () => metadata.dashboards);
const text =
metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language);
return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' }));
Expand All @@ -170,6 +174,6 @@ export function useNavTargetLabel(): NavTargetLabelResolver {
return undefined;
}
},
[metadata, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
[metadata, bundledReads, objectLabel, viewLabel, dashboardLabel, language, t, drafts],
);
}
8 changes: 7 additions & 1 deletion ui/packages/app-shell/src/hooks/useObjectPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,14 @@ export function useObjectPresence(name: string): ObjectPresenceReading {
const { app } = useExpressionContext();
const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined;
const inApp = typeof app?.name === 'string' && app.name !== '';
//
// objectstack#28645 — it asks with `views: false` (the provider's hand-off,
// `MetadataProvider`): whether an object is there reads no views, so this
// question never reads the `/meta/view` list.
if ((packageId || inApp) && readObjectScope && getTypeStatus?.('object') === 'idle') {
const scope = readObjectScope(packageId ? { packageId, names: [name] } : { names: [name] });
type ReadScope = NonNullable<typeof readObjectScope>;
const readNames = readObjectScope as (scope: Parameters<ReadScope>[0] & { views: false }) => ReturnType<ReadScope>;
const scope = readNames(packageId ? { packageId, names: [name], views: false } : { names: [name], views: false });
const presence: ObjectPresence = !scope.settled
? 'unknown'
: scope.objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === name)
Expand Down
6 changes: 4 additions & 2 deletions ui/packages/app-shell/src/hooks/useRecentItemLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { useMemo } from 'react';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { RecentItem } from '../context/RecentItemsProvider.js';
import { useMetadata } from '../providers/MetadataProvider.js';
import { bundledFirst, useBundledReads } from '../providers/bundledReads.js';
import { findByName, metadataText, useNavTargetLabel, type LabelledRecord } from './useNavTargetLabel.js';

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

Expand All @@ -102,7 +104,7 @@ export function useRecentItemLabel(
case 'dashboard':
return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name;
case 'page': {
const text = metadataText(findByName(metadata.pages, item.name)?.label, language);
const text = metadataText(bundledFirst(bundledReads, 'page', item.name, () => metadata.pages)?.label, language);
return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name;
}
case 'report': {
Expand All @@ -115,6 +117,6 @@ export function useRecentItemLabel(
return item.label;
}
},
[targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language, packages],
[targetLabel, metadata, bundledReads, objectPluralLabel, pageLabel, reportLabel, language, packages],
);
}
Loading
Loading