Skip to content
32 changes: 32 additions & 0 deletions .changeset/12811-scoped-object-metadata-app-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/app-shell': patch
---

perf(app-shell): a console open reads the current app's objects, not the whole object registry (objectui#12811)

Every console open used to request `GET /api/v1/meta/object` for every registered object (97 objects
and 745 KB on the measured backend), whichever app it opened. Opening an app's root or an object's
list now requests `GET /api/v1/meta/object?package=ID` with the package id the app's served item
carries. An object the route needs from outside that package is requested by name: the route's object,
the objects its lookup fields point at, the objects the app's navigation entries name in
`requiresObject`, and, while the command palette is open, the navigation's objects. The header's
view-tab labels and the bell's activity feed read the same scope, and `useObjectPresence` asks an
object by name inside such an app, until something loads the whole registry. Until those answers land, the route shows
its skeleton, never an empty list or a "not found".

What still reads the whole registry, as before:

- An app item that carries no package id, an app whose package holds no objects of its own (a platform
app such as Setup, whose entries all open other packages' objects), and every route outside an app
(the home page).
- A record overlay (`?recordId=`, `?form=`) and every record route: a record's related lists come from
every object that points at its object, in any package.
- An object list whose views open records in an overlay the URL does not carry (`modal`, `split`,
`popover`).
- Dashboards, reports, pages, search, Studio, the metadata admin and the app designer.
- An action that opens an object's form in a modal, when it opens: the form's subforms come from every
object. The console no longer loads the whole registry for this at startup.

`useMetadata().objects` keeps its meaning: it is the whole registry wherever it is read. The package
read is refreshed and dropped with the `object` type (`refresh`, `invalidate`, an organization switch)
and is never served from a clock.
16 changes: 16 additions & 0 deletions .changeset/12811-scoped-object-metadata-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/react': minor
---

feat(react): `MetadataContextValue.readObjectScope`, the object definitions one app reads (objectui#12811)

A new optional member of the metadata context. `readObjectScope({ packageId, names })` answers the
objects of one package, read with `GET /api/v1/meta/object?package=ID`, plus each of `names` that the
package does not hold, read by name. Each definition is shaped exactly as the same object in
`objects`. The answer carries `objects`, `served` (the same definitions as the server served them,
before views are merged, as `getItemsByType('object')` holds the whole list), `missing` (the names the
server answered without a definition) and `settled` (the package list and every name have answered). With no `packageId` it is
the whole registry, as `objects` is.

`objects` is unchanged: it is the whole registry. A context value without the member (a hand-rolled
one in a test, an older host) has nothing narrower to offer, and its readers read `objects`.
76 changes: 51 additions & 25 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { appServesNoNavigation } from './appServesNoNavigation.js';
import { useNavMemberGates, navEntriesShownToMember } from '../hooks/useNavMemberGates.js';
import { resolveSetupAppPath } from './ConsoleShell.js';
import { collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js';
import { readAppObjectScope } from './appObjectScope.js';
import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js';

// Route-based code splitting — lazy-load less-frequently-used routes
Expand Down Expand Up @@ -196,7 +197,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
const navigate = useNavigate();
const location = useLocation();
const { appName } = useParams();
const { apps, objects: allObjects, loading: metadataLoading, ensureType, getTypeStatus, error: metadataError, refresh: refreshMetadata } = useMetadata();
// ⛔ `objects` is not destructured here: reading it loads the whole registry
// (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;
// 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 All @@ -207,29 +211,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
const { t } = useObjectTranslation();
const { objectLabel, objectPluralLabel } = useObjectLabel();

// objectui#12809 — the lazy metadata buckets the CURRENT route reads at
// render (`appRouteMetadataReads.ts` declares them per route). This used to
// be a preload of all four (`object`, `dashboard`, `report`, `page`) that held
// the splash on every route; now the splash holds only for the current
// route's, and a later navigation onto a route whose buckets have not landed
// shows a skeleton in the route slot (below) instead of an empty list or a
// "not found". The record-form overlay (`?form=`) edits a record of an object,
// so it reads `object` on any route.
const hostRoutePaths = useMemo(
() => [...collectRoutePaths(extraRoutes), ...collectRoutePaths(extraRoutesNoApp)],
[extraRoutes, extraRoutesNoApp],
);
const appSubPath = location.pathname.split('/').filter(Boolean).slice(2).join('/');
const routeReads = metadataReadsForAppRoute(appSubPath, hostRoutePaths);
const routeReadsKey = [
...routeReads,
...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []),
].join(',');
const routeReadsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus);
// Set once the first frame past the splash has rendered. After it, a route
// waiting on its buckets draws its skeleton inside the console chrome rather
// than taking the whole console back to the splash.
const firstPaintDone = useRef(false);

// Hidden apps (`App.hidden`) are excluded from app-listing surfaces
// (sidebar switcher, home grid, app switcher). The active app for the
Expand Down Expand Up @@ -287,6 +268,49 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// the recent-items tracker below matches it against the URL (objectui#11818).
const appSegment = appName ?? appRouteSegment(activeApp);

// objectui#12809 — the lazy metadata buckets the CURRENT route reads at
// render (`appRouteMetadataReads.ts` declares them per route). This used to
// be a preload of all four (`object`, `dashboard`, `report`, `page`) that held
// the splash on every route; now the splash holds only for the current
// route's, and a later navigation onto a route whose buckets have not landed
// shows a skeleton in the route slot (below) instead of an empty list or a
// "not found". The record-form overlay (`?form=`) edits a record of an object,
// so it reads `object` on any route.
const hostRoutePaths = useMemo(
() => [...collectRoutePaths(extraRoutes), ...collectRoutePaths(extraRoutesNoApp)],
[extraRoutes, extraRoutesNoApp],
);
const appSubPath = location.pathname.split('/').filter(Boolean).slice(2).join('/');
const routeReads = metadataReadsForAppRoute(appSubPath, hostRoutePaths);

// objectui#12811 — which objects this route and the chrome around it read:
// the app's object scope on the routes `appObjectScope.ts` names, inside an
// app whose served item carries its package; the whole registry everywhere
// else, as before (that module says why).
const { candidate: scopeCandidate, scope: objectScope, ready: objectScopeReady } = readAppObjectScope(
readObjectScope,
activeApp,
appSubPath,
location.search,
hostRoutePaths,
);
// Until the app list has loaded, a scope candidate does not know its package
// yet, and reads nothing (the splash is up).
const readsWholeRegistry = !scopeCandidate || (!metadataLoading && !objectScope);
const allObjects: any[] = objectScope
? objectScope.objects
: readsWholeRegistry ? metadata.objects : NO_OBJECTS;
const routeReadsKey = [
...routeReads.filter((type) => type !== 'object' || readsWholeRegistry),
...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []),
].join(',');
const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus);
const routeReadsSettled = bucketsSettled && (!objectScope || objectScopeReady);
// Set once the first frame past the splash has rendered. After it, a route
// waiting on its buckets draws its skeleton inside the console chrome rather
// than taking the whole console back to the splash.
const firstPaintDone = useRef(false);

// A normal app was requested but isn't present in the loaded metadata — the
// post-publish readiness lag, or a genuinely-missing app. Applies in BOTH
// preview and published mode (preview already guarded this; published used to
Expand Down Expand Up @@ -1167,7 +1191,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
<RouteFader className="h-full">
{/* objectui#12809 — the route's metadata has not landed: its
skeleton, never its empty or "not found" state. */}
{!routeReadsSettled ? <RouteMetadataSkeleton reads={routeReadsKey} /> : (
{!routeReadsSettled ? <RouteMetadataSkeleton reads={routeReadsKey || 'object'} /> : (
<Routes>
<Route
path="/"
Expand Down Expand Up @@ -1563,6 +1587,8 @@ function searchParamsHasRecordForm(search: string): boolean {
return sp.has(RECORD_FORM_PARAM) || sp.has(RECORD_FORM_OBJECT_PARAM);
}

const NO_OBJECTS: any[] = [];

/**
* objectui#12809 — whether every bucket in `readsKey` (comma-joined) has
* answered: ready, or failed (a failure is an answer the route renders).
Expand Down
6 changes: 6 additions & 0 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider.js';
import { withSettleSignal } from '../observability/settleSignal.js';
import { MetadataProvider, useMetadata } from '../providers/MetadataProvider.js';
import { useAppObjectScopePrefetch } from './appObjectScope.js';
import { appRouteSegment } from '../utils/appRoute.js';
import { SETUP_APP_PACKAGE_ID, SETUP_APP_NAME } from './setupApp.js';
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
Expand Down Expand Up @@ -93,6 +94,11 @@ export function LoadingFallback() {
*/
function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unknown; children: ReactNode }) {
const { modalHandler, modalElement } = useActionModal(dataSource);
// objectui#12811 — the current app's object scope starts here, as soon as the
// app list names the app: `AppContent` mounts only after the session's
// permission read, and the whole object list this root used to load (through
// `useActionModal`) loaded beside that read.
useAppObjectScopePrefetch();
const runtime = useConsoleActionRuntime({ dataSource });
return (
<ActionProvider
Expand Down
Loading
Loading