From b50ba2b8a6047eb43eb3c4123a90bed1572373f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:19:04 +0000 Subject: [PATCH 1/7] perf(app-shell,react): a console open reads the current app's objects, not the whole registry (objectui#12811) The open path's three readers of useMetadata().objects (AppContent, useActionModal, useNavMemberGates) loaded GET /api/v1/meta/object for every registered object on every console open. They now read the app's object scope: the new optional MetadataContextValue.readObjectScope asks ?package= with the package id the app's served item carries, and by name each object outside the package the open reads. objects keeps its meaning, the whole registry, for every other reader. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- .../12811-scoped-object-metadata-app-shell.md | 28 ++ .../12811-scoped-object-metadata-react.md | 15 + packages/app-shell/src/console/AppContent.tsx | 169 +++++++++-- .../AppContent.objectScope-12811.test.tsx | 268 ++++++++++++++++++ .../openPathObjectReaders-12811.test.ts | 165 +++++++++++ .../app-shell/src/hooks/useActionModal.tsx | 17 +- .../app-shell/src/hooks/useNavMemberGates.ts | 32 ++- .../src/providers/MetadataProvider.tsx | 215 +++++++++++++- ...etadataProvider.objectScope-12811.test.tsx | 254 +++++++++++++++++ .../MetadataProvider.requestBudget.test.tsx | 7 +- .../react/src/context/AppShellContext.tsx | 18 ++ 11 files changed, 1135 insertions(+), 53 deletions(-) create mode 100644 .changeset/12811-scoped-object-metadata-app-shell.md create mode 100644 .changeset/12811-scoped-object-metadata-react.md create mode 100644 packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx create mode 100644 packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts create mode 100644 packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx diff --git a/.changeset/12811-scoped-object-metadata-app-shell.md b/.changeset/12811-scoped-object-metadata-app-shell.md new file mode 100644 index 0000000000..1b5af865e3 --- /dev/null +++ b/.changeset/12811-scoped-object-metadata-app-shell.md @@ -0,0 +1,28 @@ +--- +'@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, an object a navigation entry's `requiresObject` names, and, +while the command palette is open, the navigation's objects. 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, 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. diff --git a/.changeset/12811-scoped-object-metadata-react.md b/.changeset/12811-scoped-object-metadata-react.md new file mode 100644 index 0000000000..4b57bdcfd4 --- /dev/null +++ b/.changeset/12811-scoped-object-metadata-react.md @@ -0,0 +1,15 @@ +--- +'@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`, `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`. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index cf0123d4e9..8795336826 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -8,7 +8,7 @@ * /organizations) is provided by `createConsole` from @object-ui/app-shell. */ -import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams } from 'react-router-dom'; +import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams, matchRoutes } from 'react-router-dom'; import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo, type ReactNode } from 'react'; import { useAssistant } from '../assistant/assistantBus.js'; import { ModalForm } from '@object-ui/plugin-form'; @@ -34,7 +34,7 @@ import { useHomePath } from '../hooks/useHomePath.js'; import { useSignedInUserLocale } from '../hooks/useUserLocale.js'; import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view'; -import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js'; +import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_DRAWER_PARAM, COMMAND_PALETTE_PARAM } from '../urlParams.js'; import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js'; import { resolveHref, type NavTemplateContext } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; @@ -54,7 +54,7 @@ import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js'; 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 { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js'; import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; // Route-based code splitting — lazy-load less-frequently-used routes @@ -196,7 +196,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 @@ -207,29 +210,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 @@ -287,6 +267,72 @@ 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. + // On the routes `APP_OBJECT_SCOPE_ROUTES` names, inside an app whose served + // item carries its package, that is the app's object scope + // (`readObjectScope`): the package's objects, plus by name the route's + // object and its lookup targets when the package does not hold them, and the + // navigation's objects while the command palette is open. Everywhere else it + // is the whole registry, read as `objects`, as before: a record surface + // derives its related lists from every object that points at it, so a + // record overlay (`?recordId=`, `?form=`) reads the whole registry, and so + // does a list whose views open records in an in-page overlay the URL does + // not carry (`modal`, `split`, `popover`). An app item without a package id + // has no package to scope by. + const scopeRoute = matchObjectScopeRoute(appSubPath, hostRoutePaths); + const scopePackageId = + typeof activeApp?._packageId === 'string' && activeApp._packageId ? activeApp._packageId : undefined; + const scopeCandidate = !!scopeRoute && !!readObjectScope && !searchParamsHasRecordOverlay(location.search); + let objectScope: ReturnType> | null = null; + if (scopeCandidate && scopePackageId && readObjectScope) { + const names: string[] = []; + if (scopeRoute.objectName) names.push(scopeRoute.objectName); + if (new URLSearchParams(location.search).has(COMMAND_PALETTE_PARAM)) { + names.push(...navigationObjectNames(activeApp?.navigation)); + } + const first = readObjectScope({ packageId: scopePackageId, names }); + const routeDef = scopeRoute.objectName + ? first.objects.find((o: any) => o?.name === scopeRoute.objectName) + : undefined; + if (!routeDef || !opensRecordsInPageOverlay(routeDef)) { + const lookups = routeDef ? lookupTargetNames(routeDef) : []; + objectScope = lookups.length > 0 + ? readObjectScope({ packageId: scopePackageId, names: [...names, ...lookups] }) + : first; + } + } + // 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 || objectScope.settled); + // 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 @@ -1167,7 +1213,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {/* objectui#12809 — the route's metadata has not landed: its skeleton, never its empty or "not found" state. */} - {!routeReadsSettled ? : ( + {!routeReadsSettled ? : ( ({ path })), ...hostPaths.map((path) => ({ path }))]; + const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`); + const match = matches?.[matches.length - 1]; + if (!match || !APP_OBJECT_SCOPE_ROUTES.includes(match.route.path)) return null; + return { objectName: match.params.objectName }; +} + +/** A record overlay the URL carries: the detail drawer, or the record form. */ +function searchParamsHasRecordOverlay(search: string): boolean { + return new URLSearchParams(search).has(RECORD_DRAWER_PARAM) || searchParamsHasRecordForm(search); +} + +const IN_PAGE_RECORD_OVERLAYS = new Set(['modal', 'split', 'popover']); + +/** + * Whether a list of this object opens a record in an overlay the URL does not + * carry: `ObjectView` mounts `RecordDetailView` inside it with the list's + * objects, so such a list reads the whole registry. + */ +function opensRecordsInPageOverlay(def: any): boolean { + const views = [def?.list, ...Object.values((def?.listViews ?? {}) as Record)]; + return views.some((view) => IN_PAGE_RECORD_OVERLAYS.has(view?.navigation?.mode)); +} + +/** The objects an object's lookup and master-detail fields point at. */ +function lookupTargetNames(def: any): string[] { + const fields = def?.fields; + const defs: any[] = Array.isArray(fields) ? fields : Object.values((fields ?? {}) as Record); + const out = new Set(); + for (const field of defs) { + if ((field?.type === 'lookup' || field?.type === 'master_detail') && typeof field.reference === 'string' && field.reference) { + out.add(field.reference); + } + } + return [...out]; +} + +/** The objects an app's navigation opens, at every group depth. */ +function navigationObjectNames(navigation: unknown): string[] { + const out: string[] = []; + const walk = (items: unknown) => { + if (!Array.isArray(items)) return; + for (const item of items as Array<{ type?: string; objectName?: unknown; children?: unknown }>) { + if (item?.type === 'object' && typeof item.objectName === 'string' && item.objectName) out.push(item.objectName); + walk(item?.children); + } + }; + walk(navigation); + return out; +} + /** * objectui#12809 — whether every bucket in `readsKey` (comma-joined) has * answered: ready, or failed (a failure is an answer the route renders). diff --git a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx new file mode 100644 index 0000000000..f6faca5416 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12811 — the object requests one console open issues. + * + * Measured before this card: opening `/apps/crm_enterprise/crm_account` asked + * `GET /api/v1/meta/object` for the whole registry (97 objects, 745 KB), + * because three readers on the open path read `useMetadata().objects`: + * `AppContent` (for the route, the chrome and the recent-items tracker), + * `useActionModal` (mounted at the console root) and `useNavMemberGates` (the + * sidebar's capability prune). The open path now reads the app's object scope: + * `?package=` with the app's package id, and by name the route's object and + * its lookup targets when the package does not hold them. + * + * The harness is the REAL `MetadataProvider`, with a client double that records + * each call as its URL would read, under the real `AppContent`. The chrome is a + * stand-in that does what the sidebar's capability gate does (one + * `requiresObject` entry), and `useActionModal` is mounted beside it, as + * `ConsoleShell` mounts it. + * + * CONTROLs: an app item with no package id asks the whole registry, as before; + * a record overlay (`?recordId=`) reads the whole registry, because a record's + * related lists come from every object that points at it. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React, { useEffect } from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useNavigate, type NavigateFunction } from 'react-router-dom'; +import { AuthProvider, ActiveOrganizationStorage } from '@object-ui/auth'; +import type { AuthClient, AuthOrganization } from '@object-ui/auth'; + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () => null, + EditAppPage: () => null, + DashboardDesignPage: () => null, +})); +// The chrome: what the sidebar's capability gate asks, and the list it is handed. +vi.mock('../../layout/ConsoleLayout', async () => { + const { useNavMemberGates } = await import('../../hooks/useNavMemberGates'); + return { + ConsoleLayout: ({ children, objects }: { children?: React.ReactNode; objects: Array<{ name: string }> }) => { + const gates = useNavMemberGates(); + return ( +
o.name).join(',')}> + {String(gates.checkCapability('object', 'cloud_only'))} + {children} +
+ ); + }, + }; +}); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: ({ objects }: { objects: Array<{ name: string }> }) => ( +
{objects.map((o) => o.name).join(',')}
+ ), +})); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label, objectPluralLabel: () => '' }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { useActionModal } from '../../hooks/useActionModal'; + +const CRM = 'app.crm'; +const OBJECTS = [ + { name: 'crm_account', _packageId: CRM, fields: { owner: { type: 'lookup', reference: 'sys_user' }, parent: { type: 'lookup', reference: 'crm_account' } } }, + { name: 'crm_contact', _packageId: CRM, fields: { account: { type: 'lookup', reference: 'crm_account' } } }, + { name: 'crm_case', _packageId: 'app.service', fields: { account: { type: 'lookup', reference: 'crm_account' } } }, + { name: 'sys_user', _packageId: 'plugin.auth', fields: {} }, +]; +const NAVIGATION = [ + { id: 'n1', type: 'object', label: 'Accounts', objectName: 'crm_account' }, + { id: 'n2', type: 'object', label: 'Cases', objectName: 'crm_case' }, +]; + +interface Server { + calls: string[]; + apps: unknown[]; + holdByName: boolean; + release: () => void; +} +const server: Server = { calls: [], apps: [], holdByName: false, release: () => {} }; + +function makeAdapter() { + const held: Array<() => void> = []; + server.release = () => held.splice(0).forEach((go) => go()); + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: (type: string, options?: { packageId?: string }) => { + server.calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); + const all = type === 'object' ? OBJECTS : type === 'app' ? server.apps : []; + const items = options?.packageId ? all.filter((o: any) => o._packageId === options.packageId) : all; + return Promise.resolve({ type, items: structuredClone(items) }); + }, + getItem: (type: string, name: string) => { + server.calls.push(`${type}/${name}`); + const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + const answer = () => + item ? Promise.resolve({ type, name, item: structuredClone(item) }) : Promise.reject(new Error('404')); + if (!server.holdByName) return answer(); + return new Promise((resolve, reject) => held.push(() => answer().then(resolve, reject))); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +const USER = 'u_12811'; +const ORG: AuthOrganization = { id: 'org_a', name: 'Org A', slug: 'a' }; + +function authClient(): AuthClient { + return { + getSession: vi.fn(async () => ({ + // An admin by position: the workspace-admin verdict needs no member row. + user: { id: USER, name: 'Ada', email: 'ada@test.com', role: 'admin', positions: ['admin'] }, + session: { token: 'tok-12811', activeOrganizationId: ORG.id }, + })), + signOut: vi.fn(async () => undefined), + getConfig: vi.fn(async () => ({ features: {} })), + listOrganizations: vi.fn(async () => [ORG]), + getActiveOrganization: vi.fn(async () => ORG), + setActiveOrganization: vi.fn(async () => ORG), + getActiveMember: vi.fn(async () => ({ id: 'm1', organizationId: ORG.id, userId: USER, role: 'owner' })), + } as unknown as AuthClient; +} + +/** `ConsoleShell` mounts `useActionModal` at the console root. */ +function ActionModalHost() { + const { modalElement } = useActionModal(dataSourceStub); + return <>{modalElement}; +} + +const nav: { current: NavigateFunction | null } = { current: null }; +function NavProbe() { + const navigate = useNavigate(); + useEffect(() => { nav.current = navigate; }); + return null; +} + +function boot(url: string) { + render( + + + + + + + } /> + + + + , + ); +} + +const objectCalls = () => server.calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); +const splashUp = () => document.querySelector('[data-boot-splash]') !== null; + +beforeEach(() => { + server.calls = []; + server.holdByName = false; + server.apps = [{ name: 'crm', label: 'CRM', _packageId: CRM, navigation: NAVIGATION }]; + localStorage.clear(); + sessionStorage.clear(); + ActiveOrganizationStorage.clear(); +}); + +afterEach(() => { + cleanup(); + ActiveOrganizationStorage.clear(); +}); + +describe('one console open asks for the app\'s objects, not the whole registry (objectui#12811)', () => { + it('asks `meta/object` with the app\'s package, and by name only the objects outside it the open reads', async () => { + boot('/apps/crm/crm_account'); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('cloud-only-entry')).toHaveTextContent('false')); + + expect(objectCalls()).not.toContain('object'); + expect(objectCalls()[0]).toBe(`object?package=${CRM}`); + // The route's lookup target outside the package, and the gate's `requiresObject` name. + expect([...objectCalls()].sort()).toEqual([`object?package=${CRM}`, 'object/cloud_only', 'object/sys_user'].sort()); + // The route element and the chrome are handed the scope. + expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact,sys_user'); + expect(screen.getByTestId('console-layout').getAttribute('data-chrome-objects')).toBe('crm_account,crm_contact,sys_user'); + }); + + it('CONTROL: an app item with no package id asks the whole registry, as before', async () => { + server.apps = [{ name: 'crm', label: 'CRM', navigation: NAVIGATION }]; + boot('/apps/crm/crm_account'); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + + expect(objectCalls()).toContain('object'); + expect(objectCalls().some((c) => c.startsWith('object?'))).toBe(false); + expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact,crm_case,sys_user'); + }); + + it('CONTROL: a record overlay reads the whole registry (a record\'s related lists come from every object)', async () => { + boot('/apps/crm/crm_account?recordId=r1'); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + + expect(objectCalls()).toContain('object'); + expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case'); + }); +}); + +describe('an object outside the package is read by name, behind the skeleton (objectui#12811)', () => { + it('holds the splash for a route object outside the package until its by-name answer lands', async () => { + server.holdByName = true; + boot('/apps/crm/crm_case'); + await waitFor(() => expect(server.calls).toContain('object/crm_case')); + expect(splashUp()).toBe(true); + expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); + + act(() => server.release()); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case')); + expect(objectCalls()).not.toContain('object'); + }); + + it('after the first paint, shows the route skeleton inside the chrome, never an empty route, until it lands', async () => { + boot('/apps/crm/crm_contact'); + await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); + + server.holdByName = true; + await act(async () => { nav.current!('/apps/crm/crm_case'); }); + await waitFor(() => expect(server.calls).toContain('object/crm_case')); + + expect(screen.getByTestId('console-layout')).toBeInTheDocument(); + expect(screen.getByTestId('route-metadata-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); + expect(splashUp()).toBe(false); + + act(() => server.release()); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case')); + expect(screen.queryByTestId('route-metadata-skeleton')).not.toBeInTheDocument(); + expect(objectCalls()).not.toContain('object'); + }); +}); diff --git a/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts new file mode 100644 index 0000000000..a8ddc3c971 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12811 — the enumeration of `useMetadata().objects` readers. + * + * Reading `objects` loads the whole object registry (`GET /api/v1/meta/object`, + * 97 objects and 745 KB on the card's backend). A console open of an app's + * object list now reads the app's object scope instead (`readObjectScope`), + * so a reader that is mounted on that open and reads `objects` at render + * brings the whole registry back onto it. + * + * This file finds every reader of `objects` off the metadata context in the + * packages' and the console's source (a `{ objects }` destructured from + * `useMetadata()`, `useMetadata().objects`, or `.objects` on a binding of + * `useMetadata()`), and fails on a reader the table below does not classify, + * and on a row that names no reader. Each row says where the reader runs: + * + * - `scope`: on the open path, it reads `readObjectScope`, and `objects` + * only where no package scopes the read; + * - `action`: it reads `objects` only when an action needs it, after + * `ensureType('object')`; + * - `off`: it is not mounted on an app's object-list open, and keeps the + * whole registry (the control: its own pins stay unchanged). + * + * Measured once on HotCRM (a Chromium open of `/apps/crm_enterprise/crm_account` + * with a stack-recording getter): the readers that touched `objects` were + * `AppContent`, `useActionModal` and `useNavMemberGates`. The `request` pin + * beside this file (`AppContent.objectScope-12811.test.tsx`) re-derives the + * requests; this one re-derives who reads. + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..', '..'); + +type Kind = 'scope' | 'action' | 'off'; + +/** Every reader of `objects` off the metadata context, and where it runs. */ +const OBJECTS_READERS: Record = { + 'packages/app-shell/src/console/AppContent.tsx': { + kind: 'scope', + why: 'the route, the chrome and the recent-items tracker read the scope on APP_OBJECT_SCOPE_ROUTES; a record overlay or another route reads objects', + }, + 'packages/app-shell/src/hooks/useNavMemberGates.ts': { + kind: 'scope', + why: "the capability gate reads the app's scope; with no app package (the home feeds) it reads objects", + }, + 'packages/app-shell/src/hooks/useActionModal.tsx': { + kind: 'action', + why: "an object's form modal: the open waits for the whole list, whose subforms come from every object", + }, + 'packages/app-shell/src/hooks/useConsoleActionRuntime.tsx': { kind: 'off', why: 'read inside action handlers, when an action runs' }, + 'packages/plugin-grid/src/ObjectGrid.tsx': { kind: 'off', why: 'read inside an action handler, when an action runs' }, + 'packages/app-shell/src/hooks/useNavTargetLabel.ts': { kind: 'off', why: 'read when a label is resolved for a nav entry without its own label, or a view entry' }, + 'packages/app-shell/src/hooks/useRecentItemLabel.ts': { kind: 'off', why: 'read when a recent object or view entry is labelled' }, + 'packages/app-shell/src/layout/ActivityObjectLabel.tsx': { kind: 'off', why: "the inbox and the home rail's activity rows" }, + 'packages/app-shell/src/chrome/CommandPalette.tsx': { kind: 'off', why: "the Studio scope's object list, read while the palette lists Studio entries" }, + 'packages/app-shell/src/console/home/HomeLayout.tsx': { kind: 'off', why: 'the home page, outside any app' }, + 'packages/app-shell/src/console/home/HomePage.tsx': { kind: 'off', why: 'the home page, outside any app' }, + 'packages/app-shell/src/views/DashboardView.tsx': { kind: 'off', why: "a dashboard route: its widgets' objects" }, + 'packages/app-shell/src/views/ReportView.tsx': { kind: 'off', why: "a report route: its columns' objects" }, + 'packages/app-shell/src/views/PageView.tsx': { kind: 'off', why: "a page route: its record actions' objects" }, + 'packages/app-shell/src/views/RecordFormPage.tsx': { kind: 'off', why: 'a record form route' }, + 'packages/app-shell/src/views/SearchResultsPage.tsx': { kind: 'off', why: 'the search route: every searchable object' }, + 'packages/app-shell/src/views/global-search-renderer.tsx': { kind: 'off', why: 'the global search block' }, + 'packages/app-shell/src/views/InterfaceListPage.tsx': { kind: 'off', why: 'the interface list route' }, + 'packages/app-shell/src/views/nav-menu-renderer.tsx': { kind: 'off', why: 'the nav:menu page block' }, + 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx': { kind: 'off', why: 'the metadata admin' }, + 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx': { kind: 'off', why: 'the metadata admin' }, + 'packages/app-shell/src/views/metadata-admin/previews/ScreenPreview.tsx': { kind: 'off', why: "the metadata admin's form-screen preview" }, + 'packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx': { kind: 'off', why: 'Studio' }, + 'packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx': { kind: 'off', why: 'Studio' }, + 'packages/plugin-designer/src/pages/CreateAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" }, + 'packages/plugin-designer/src/pages/EditAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" }, + 'apps/console/src/pages/developer/FlowRunsPage.tsx': { kind: 'off', why: 'the flow runs page' }, + 'apps/console/src/pages/system/AuditLogPage.tsx': { kind: 'off', why: 'the audit log page' }, +}; + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist' || name === '__tests__') continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) sourceFiles(path, out); + else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) && !name.endsWith('.d.ts')) out.push(path); + } + return out; +} + +const isUseMetadataCall = (node: ts.Node | undefined): boolean => + !!node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'useMetadata'; + +/** Whether the module reads `objects` off the metadata context, by any of the three spellings. */ +function readsObjects(path: string): boolean { + const source = readFileSync(path, 'utf8'); + if (!source.includes('useMetadata(')) return false; + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const bindings = new Set(); + let found = false; + const collect = (node: ts.Node) => { + if (ts.isVariableDeclaration(node) && isUseMetadataCall(node.initializer)) { + if (ts.isIdentifier(node.name)) bindings.add(node.name.text); + else if (ts.isObjectBindingPattern(node.name)) { + for (const el of node.name.elements) { + const key = el.propertyName ?? el.name; + if (ts.isIdentifier(key) && key.text === 'objects') found = true; + } + } + } + ts.forEachChild(node, collect); + }; + collect(file); + const access = (node: ts.Node) => { + if (ts.isPropertyAccessExpression(node) && node.name.text === 'objects') { + const target = node.expression; + if (isUseMetadataCall(target) || (ts.isIdentifier(target) && bindings.has(target.text))) found = true; + } + ts.forEachChild(node, access); + }; + access(file); + return found; +} + +function discoveredReaders(): string[] { + const roots = [ + ...readdirSync(join(REPO_ROOT, 'packages')).map((pkg) => join(REPO_ROOT, 'packages', pkg, 'src')), + join(REPO_ROOT, 'apps', 'console', 'src'), + ].filter((dir) => { + try { + return statSync(dir).isDirectory(); + } catch { + return false; + } + }); + return roots + .flatMap((dir) => sourceFiles(dir)) + .filter(readsObjects) + .map((path) => relative(REPO_ROOT, path).split('\\').join('/')) + .sort(); +} + +describe('the readers of `useMetadata().objects`, each classified (objectui#12811)', () => { + const found = discoveredReaders(); + + it('finds the readers it knows are there (the census reads source)', () => { + expect(found).toContain('packages/app-shell/src/console/AppContent.tsx'); + expect(found).toContain('packages/plugin-designer/src/pages/CreateAppPage.tsx'); + }); + + it('classifies every reader, and every row names a reader', () => { + expect(found).toEqual(Object.keys(OBJECTS_READERS).sort()); + }); + + it('an open-path reader reads the scope, and an action-time reader waits for the list', () => { + for (const [path, { kind }] of Object.entries(OBJECTS_READERS)) { + const source = readFileSync(join(REPO_ROOT, path), 'utf8'); + if (kind === 'scope') expect(source, path).toContain('readObjectScope'); + if (kind === 'action') expect(source, path).toContain("ensureType('object')"); + } + }); +}); diff --git a/packages/app-shell/src/hooks/useActionModal.tsx b/packages/app-shell/src/hooks/useActionModal.tsx index c023895448..557d070360 100644 --- a/packages/app-shell/src/hooks/useActionModal.tsx +++ b/packages/app-shell/src/hooks/useActionModal.tsx @@ -156,7 +156,15 @@ export function useActionModal(dataSource?: any) { // `getItem` fetches ONE named item on demand, so resolving a modal target // never drags in the whole (lazily loaded) page or object list — this hook is // mounted at the console root, where an eager list read would cost every page. - const { objects, getItem } = useMetadata(); + // + // objectui#12811 — the same holds for `objects`, which the hook read on every + // render and so loaded the whole registry on every console open. It is read + // now only while an object's form modal is open, and the open waits for it + // (`ensureType('object')` below): the form view's subforms are derived from + // every object (`attachInlineSubforms`), so the whole list is the read this + // layout needs, and an action is when it is needed. + const metadata = useMetadata(); + const { getItem, ensureType } = metadata; const close = useCallback((r: ActionResult) => { setState((s) => { @@ -233,11 +241,14 @@ export function useActionModal(dataSource?: any) { // everywhere at once. Byte-identical to what #4764 settled on. return { success: false, error: modalTargetRefusalMessage({ target: name }) }; } + if (d.objectName && !d.content && !(d.fields || (d as any).sections)) { + await ensureType('object'); + } return new Promise((resolve) => { setState({ d, resolve }); }); }, - [resolveModalTarget], + [resolveModalTarget, ensureType], ); let modalElement: React.ReactNode = null; @@ -255,7 +266,7 @@ export function useActionModal(dataSource?: any) { // New/Edit modal in AppContent so action-opened forms stay consistent. const viewLayout = (d.fields || (d as any).sections) ? {} - : resolveFormViewLayout(objects.find((o: any) => o?.name === d.objectName)); + : resolveFormViewLayout(metadata.objects.find((o: any) => o?.name === d.objectName)); modalElement = ( evaluateNavigationVisibility(expr, evaluator), [evaluator], @@ -107,20 +109,40 @@ export function useNavMemberGates(): NavMemberGates { // Runtime capability gate: hide nav items targeting objects/services // not registered in this runtime (e.g. cloud-only `sys_app`). - const { objects: metadataObjects } = useMetadata(); + // + // objectui#12811 — inside an app whose served item names its package, the + // gate reads that app's object scope, not the whole registry: an object the + // package holds is registered, and one outside it is asked for by name when + // an entry names it (`requiresObject`), and is registered when the server + // answers with its definition. Until that answer, it reads as registered, + // as every object did while the whole list loaded. Outside an app (the home + // page's feeds) there is no package, and the gate reads the whole registry. + const metadata = useMetadata(); + const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; + const readObjectScope = packageId ? metadata.readObjectScope : undefined; + const scope = readObjectScope ? readObjectScope({ packageId }) : null; + const metadataObjects = scope ? scope.objects : metadata.objects; + const missingObjects = scope ? scope.missing : NO_MISSING; const registeredObjectNames = useMemo( () => new Set((metadataObjects || []).map((o: any) => o?.name).filter(Boolean)), [metadataObjects], ); + const missingObjectNames = useMemo(() => new Set(missingObjects), [missingObjects]); const checkCap = useCallback( (kind: 'object' | 'service', name: string): boolean => { if (kind === 'object') { - if (registeredObjectNames.size === 0) return true; - return registeredObjectNames.has(name); + if (!readObjectScope) { + if (registeredObjectNames.size === 0) return true; + return registeredObjectNames.has(name); + } + if (registeredObjectNames.has(name)) return true; + if (missingObjectNames.has(name)) return false; + readObjectScope({ packageId, names: [name] }); + return true; } return true; }, - [registeredObjectNames], + [registeredObjectNames, missingObjectNames, readObjectScope, packageId], ); return { evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, can }; diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index 370553ac34..5da2640573 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -39,6 +39,29 @@ interface TypeCacheEntry { type ItemPromiseMap = Map>; +/** + * One package's object definitions, plus the objects outside it that a reader + * named (objectui#12811): what `readObjectScope` answers from. + * + * `items` is the package list followed by the by-name extras. Like every cache + * array here it is REPLACED on each write, never mutated, because + * `composeObjects` keys its composed list on it. No timestamp: an entry is + * dropped or refetched only with the `object` type (`invalidate`, `refresh`, + * an organization or preview-mode switch). + */ +interface ObjectScopeEntry { + status: MetadataTypeStatus; + items: any[]; + /** The package list has answered once; a refetch keeps this, and the items. */ + answered: boolean; + /** The names read by name for this scope, and whether each has answered. */ + names: Map; + /** The names the server answered without a definition. Replaced on change. */ + missing: string[]; +} + +const NO_NAMES: readonly string[] = Object.freeze([]) as readonly string[]; + interface MetadataProviderProps { children: ReactNode; adapter: ObjectStackAdapter; @@ -652,6 +675,8 @@ function removeNoOrgSeedEntry(type: string): void { export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: MetadataProviderProps) { const cacheRef = useRef>(new Map()); const itemPromisesRef = useRef>(new Map()); + // objectui#12811 — the package-scoped object reads, keyed by package id. + const scopesRef = useRef>(new Map()); // Types whose session entry this boot wrote under NO_ORG_SCOPE because the // active organization had not resolved yet (objectui#5243). const unscopedSeedTypesRef = useRef>(new Set()); @@ -692,7 +717,8 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: // let consumers refetch through the new source. // // ⚠️ Deliberately skipped on MOUNT (objectui#4042). Consumers read metadata - // during the FIRST render — `useActionModal` reads `objects`, which kicks + // during the FIRST render — a reader of `objects` (as `useActionModal` was + // until objectui#12811) kicks // `ensureType('object')` and `ensureType('view')` from a render-phase getter, // before any effect has run. Clearing unconditionally in this mount effect // threw those two entries away while their requests were still in flight, so @@ -710,6 +736,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: } cacheRef.current.clear(); itemPromisesRef.current.clear(); + scopesRef.current.clear(); bump(); }, [previewDrafts, bump]); @@ -876,39 +903,127 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: [getEntry], ); + // ── objectui#12811 — package-scoped object reads ───────────────────────── + // + // The list goes through the same ingestion as the whole list + // (`normalizeSchemaReferenceKeys` here, `composeObjects` on read), and a + // by-name extra through `getItem`, which applies the same normalization. So + // a scoped definition is shaped exactly like the same object in `objects`. + // A landing for an entry that was dropped meanwhile (an invalidation, an + // organization switch) is discarded: the entry it would write to is gone. + const fetchScopeList = useCallback( + (packageId: string, entry: ObjectScopeEntry): Promise => { + entry.status = 'loading'; + const preview = previewClientRef.current; + const fetchItems: Promise = preview + ? preview.list('object', { packageId }) + : adapterRef.current.getClient().meta.getItems('object', { packageId }); + return fetchItems + .then((res: unknown) => { + if (scopesRef.current.get(packageId) !== entry) return; + const items = extractItems(res); + for (const it of items) normalizeSchemaReferenceKeys(it); + const listed = new Set(items.map((it: any) => it?.name)); + // The by-name extras stay until their own answer replaces them. + const extras = entry.items.filter((it: any) => !listed.has(it?.name) && entry.names.has(it?.name)); + entry.items = [...items, ...extras]; + entry.status = 'ready'; + entry.answered = true; + debug(`fetched object scope package=${packageId} items=${items.length}`); + bump(); + }) + .catch((err: unknown) => { + if (scopesRef.current.get(packageId) !== entry) return; + entry.status = 'error'; + entry.answered = true; + debug(`fetch failed object scope package=${packageId}`, err); + bump(); + }); + }, + [bump], + ); + + const fetchScopeName = useCallback( + (packageId: string, entry: ObjectScopeEntry, name: string): Promise => { + entry.names.set(name, 'pending'); + return getItem('object', name).then((item: any) => { + if (scopesRef.current.get(packageId) !== entry) return; + entry.names.set(name, 'answered'); + const rest = entry.items.filter((it: any) => it?.name !== name); + if (item) { + entry.items = [...rest, item]; + if (entry.missing.includes(name)) entry.missing = entry.missing.filter(n => n !== name); + } else { + if (rest.length !== entry.items.length) entry.items = rest; + if (!entry.missing.includes(name)) entry.missing = [...entry.missing, name]; + } + bump(); + }); + }, + [bump, getItem], + ); + + /** Refetch every scope in place: the items stay until the answers replace them. */ + const refreshScopes = useCallback((): Promise[] => { + // `getItem` answers from the `object` entry's by-name cache; a refresh + // must ask the server again. + getEntry('object').byName.clear(); + itemPromisesRef.current.get('object')?.clear(); + const pending: Promise[] = []; + for (const [packageId, entry] of scopesRef.current) { + if (entry.status === 'idle') continue; + pending.push(fetchScopeList(packageId, entry)); + for (const name of Array.from(entry.names.keys())) pending.push(fetchScopeName(packageId, entry, name)); + } + return pending; + }, [fetchScopeList, fetchScopeName, getEntry]); + const refresh = useCallback( async (type?: string): Promise => { if (type) { const entry = getEntry(type); entry.fetchedAt = 0; entry.byName.clear(); - await ensureType(type); + const scoped = type === 'object' ? refreshScopes() : []; + await Promise.all([ensureType(type), ...scoped]); return; } const types = Array.from(cacheRef.current.keys()).filter( t => cacheRef.current.get(t)!.status !== 'idle', ); - await Promise.all( - types.map(t => { + const scoped = refreshScopes(); + await Promise.all([ + ...types.map(t => { const entry = cacheRef.current.get(t)!; entry.fetchedAt = 0; entry.byName.clear(); return ensureType(t); }), - ); + ...scoped, + ]); }, - [ensureType, getEntry], + [ensureType, getEntry, refreshScopes], ); const invalidate = useCallback( (type: string, name?: string): void => { - const entry = cacheRef.current.get(type); + const entry = type === 'object' ? getEntry(type) : cacheRef.current.get(type); if (!entry) return; if (name) { entry.byName.delete(name); entry.items = entry.items.filter((it: any) => it?.name !== name); + if (type === 'object') { + for (const scope of scopesRef.current.values()) { + if (scope.items.some((it: any) => it?.name === name)) { + scope.items = scope.items.filter((it: any) => it?.name !== name); + } + scope.names.delete(name); + if (scope.missing.includes(name)) scope.missing = scope.missing.filter(n => n !== name); + } + } debug(`invalidated type=${type} name=${name}`); } else { + if (type === 'object') scopesRef.current.clear(); entry.status = 'idle'; entry.items = []; entry.byName.clear(); @@ -918,7 +1033,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: } bump(); }, - [bump], + [bump, getEntry], ); // ADR-0037 P2.5 — same-document live refresh: while this tree renders the @@ -1027,6 +1142,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: let cancelled = false; cacheRef.current.clear(); itemPromisesRef.current.clear(); + scopesRef.current.clear(); // `apps` is the one collection read straight off the entry // (`getEntry('app').items`) instead of through `readType`, so unlike every // lazy type it is NOT re-armed by a consumer reading it. Without this kick @@ -1093,16 +1209,86 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: }; }, [adapter, ensureType, getEntry, bump, previewDrafts]); - const value = useMemo(() => { - void version; - - const readType = (type: string): any[] => { + const readType = useCallback( + (type: string): any[] => { const entry = getEntry(type); if (entry.status === 'idle') { void ensureType(type); } return entry.items; - }; + }, + [ensureType, getEntry], + ); + + // objectui#12811 — see `MetadataContextValue.readObjectScope`. Reading it + // starts what it needs and nothing more: the package list once, and a name + // by name only after that list has answered without it. When the whole + // registry is already loaded (Studio was opened, say), the scope is cut from + // it and nothing is requested. + const readObjectScope = useCallback( + (scope: { packageId?: string; names?: readonly string[] }) => { + const packageId = scope.packageId; + if (!packageId) { + const status = getEntry('object').status; + return { + objects: composeObjects(readType('object'), readType('view')) as any[], + missing: NO_NAMES, + settled: status === 'ready' || status === 'error', + }; + } + let entry = scopesRef.current.get(packageId); + if (!entry) { + entry = { status: 'idle', items: [], answered: false, names: new Map(), missing: [] }; + scopesRef.current.set(packageId, entry); + } + const whole = getEntry('object'); + // The whole list in hand answers the scope; one in flight is waited for + // (a record route reads it), rather than asked for twice. + if (entry.status === 'idle') { + if (whole.status === 'ready') { + entry.items = whole.items.filter((it: any) => it?._packageId === packageId); + entry.status = 'ready'; + entry.answered = true; + } else if (whole.status !== 'loading') { + void fetchScopeList(packageId, entry); + } + } + let namesSettled = true; + if (scope.names && scope.names.length > 0) { + if (!entry.answered) { + namesSettled = false; + } else { + for (const name of scope.names) { + if (!name || entry.items.some((it: any) => it?.name === name)) continue; + const state = entry.names.get(name); + if (state === 'answered') continue; + if (state === 'pending') { + namesSettled = false; + continue; + } + if (whole.status === 'ready') { + const item = whole.byName.get(name); + entry.names.set(name, 'answered'); + if (item) entry.items = [...entry.items, item]; + else entry.missing = [...entry.missing, name]; + continue; + } + namesSettled = false; + void fetchScopeName(packageId, entry, name); + } + } + } + return { + objects: composeObjects(entry.items, readType('view')) as any[], + missing: entry.missing as readonly string[], + settled: entry.answered && namesSettled, + }; + }, + [fetchScopeList, fetchScopeName, getEntry, readType], + ); + + const value = useMemo(() => { + void version; const getItemsByType = (type: string): any[] => readType(type); @@ -1128,13 +1314,14 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: ensureType, getItem, getItemsByType, + readObjectScope, // Pure read — must NOT kick a fetch, so render-phase status checks // can't recurse into ensureType. getTypeStatus: (type: string) => getEntry(type).status, }; return base; - }, [version, initialLoading, initialError, ensureType, getItem, getEntry, refresh, invalidate]); + }, [version, initialLoading, initialError, ensureType, getItem, getEntry, refresh, invalidate, readType, readObjectScope]); return {children}; } diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx new file mode 100644 index 0000000000..68e8193f3e --- /dev/null +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12811 — `readObjectScope`, the object read one app needs. + * + * Measured before this card: every console open asked `GET + * /api/v1/meta/object` for the whole registry (97 objects, 745 KB on the + * card's backend), because the readers on the open path read `objects`. + * `readObjectScope({ packageId, names })` asks for the package's objects + * (`?package=`, the list door's declared scope, ADR-0048) and for each named + * object outside the package by name. + * + * What these pins hold: + * + * - a scope asks `object?package=ID` and never the bare `object` list; + * - a name outside the package is asked by name, after the package list has + * answered without it, and the scope is not `settled` until it answers; + * - a scoped definition is shaped exactly like the same object in `objects` + * (views merged, references normalized); + * - a name the server does not answer is `missing`, and settles; + * - CONTROL: `objects` is still the whole registry, and a scope with no + * package id is the whole registry too; + * - the scope is dropped and refetched with the `object` type (`invalidate`, + * `refresh`), never on a clock; + * - with the whole registry already loaded, a scope asks for nothing. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { MetadataProvider, useMetadata } from '../MetadataProvider'; + +type Ctx = ReturnType; +type Def = { name: string; _packageId?: string; fields?: Record; listViews?: Record }; + +const CRM = 'app.crm'; +const OBJECTS: Def[] = [ + { + name: 'crm_account', + _packageId: CRM, + // A legacy spelling: ingestion folds it onto `reference` on both paths. + fields: { name: { type: 'text' }, owner: { type: 'lookup', reference_to: 'sys_user' } }, + }, + { name: 'crm_contact', _packageId: CRM, fields: { account: { type: 'lookup', reference: 'crm_account' } } }, + { name: 'crm_case', _packageId: 'app.service', fields: { account: { type: 'lookup', reference: 'crm_account' } } }, + { name: 'sys_user', _packageId: 'plugin.auth', fields: { name: { type: 'text' } } }, +]; +const VIEWS = [ + { name: 'crm_account.all', object: 'crm_account', viewKind: 'list', label: 'All', isDefault: true, config: { type: 'grid', columns: ['name'] } }, + { name: 'crm_case.all', object: 'crm_case', viewKind: 'list', label: 'All', isDefault: true, config: { type: 'grid', columns: ['account'] } }, +]; + +interface Server { + calls: string[]; + /** Holds every by-name answer until released, when set. */ + holdByName: boolean; + release: () => void; +} + +/** Serves the list door with `?package=` and the by-name door, recording each call as its URL would read. */ +function makeAdapter(server: Server) { + const held: Array<() => void> = []; + server.release = () => held.splice(0).forEach((go) => go()); + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: (type: string, options?: { packageId?: string }) => { + server.calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); + const all = type === 'object' ? OBJECTS : type === 'view' ? VIEWS : []; + const items = options?.packageId ? all.filter((o: any) => o._packageId === options.packageId) : all; + return Promise.resolve({ type, items: structuredClone(items) }); + }, + getItem: (type: string, name: string) => { + server.calls.push(`${type}/${name}`); + const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + const answer = () => + item ? Promise.resolve({ type, name, item: structuredClone(item) }) : Promise.reject(Object.assign(new Error('not found'), { status: 404 })); + if (!server.holdByName) return answer(); + return new Promise((resolve, reject) => held.push(() => answer().then(resolve, reject))); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function mount() { + const server: Server = { calls: [], holdByName: false, release: () => {} }; + let latest: Ctx | null = null; + function Probe() { + latest = useMetadata(); + return null; + } + render( + + + , + ); + return { server, ctx: () => latest as unknown as Ctx }; +} + +const objectCalls = (calls: string[]) => calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); +const names = (list: unknown[]) => (list as Def[]).map((o) => o.name).sort(); +const byName = (list: unknown[], name: string) => (list as Def[]).find((o) => o.name === name); + +afterEach(() => cleanup()); + +describe('MetadataProvider.readObjectScope (objectui#12811)', () => { + it("asks for the package's objects with `?package=`, never the whole list", async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + act(() => { + m.ctx().readObjectScope!({ packageId: CRM }); + }); + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: CRM }).settled).toBe(true)); + + const scope = m.ctx().readObjectScope!({ packageId: CRM }); + expect(names(scope.objects)).toEqual(['crm_account', 'crm_contact']); + expect(scope.missing).toEqual([]); + expect(objectCalls(m.server.calls)).toEqual([`object?package=${CRM}`]); + expect(m.ctx().getTypeStatus!('object')).toBe('idle'); + // The same list for the same stored inputs, read after read (objectui#11699). + expect(m.ctx().readObjectScope!({ packageId: CRM }).objects).toBe(scope.objects); + }); + + it('asks an object outside the package by name, and is not settled until it answers', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + m.server.holdByName = true; + act(() => { + m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_account', 'crm_case'] }); + }); + // The package list answers first; only then is the name it lacks asked, by + // the next read (a reader re-renders when the list lands). + await waitFor(() => { + m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_account', 'crm_case'] }); + expect(m.server.calls).toContain('object/crm_case'); + }); + expect(objectCalls(m.server.calls)).toEqual([`object?package=${CRM}`, 'object/crm_case']); + const waiting = m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_account', 'crm_case'] }); + expect(waiting.settled).toBe(false); + expect(byName(waiting.objects, 'crm_case')).toBeUndefined(); + + act(() => m.server.release()); + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }).settled).toBe(true)); + const scope = m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }); + expect(names(scope.objects)).toEqual(['crm_account', 'crm_case', 'crm_contact']); + // Asked once: a second read with the same name asks nothing more. + expect(objectCalls(m.server.calls)).toEqual([`object?package=${CRM}`, 'object/crm_case']); + }); + + it('shapes a scoped definition exactly like the same object in the whole list', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + act(() => { + m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }); + }); + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }).settled).toBe(true)); + const scoped = m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }).objects; + + act(() => { + void m.ctx().objects; + }); + await waitFor(() => expect(m.ctx().objects).toHaveLength(OBJECTS.length)); + const whole = m.ctx().objects; + for (const name of ['crm_account', 'crm_contact', 'crm_case']) { + expect(byName(scoped, name)).toEqual(byName(whole, name)); + } + expect(byName(scoped, 'crm_account')?.listViews?.['crm_account.all']).toBeTruthy(); + expect(byName(scoped, 'crm_case')?.listViews?.['crm_case.all']).toBeTruthy(); + expect(byName(scoped, 'crm_account')?.fields?.owner.reference).toBe('sys_user'); + }); + + it('lists a name the server does not answer as missing, and settles', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + act(() => { + m.ctx().readObjectScope!({ packageId: CRM, names: ['cloud_only'] }); + }); + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: CRM, names: ['cloud_only'] }).settled).toBe(true)); + expect(m.ctx().readObjectScope!({ packageId: CRM, names: ['cloud_only'] }).missing).toEqual(['cloud_only']); + }); + + it('CONTROL: `objects` is still the whole registry, and so is a scope with no package id', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + act(() => { + void m.ctx().objects; + }); + await waitFor(() => expect(m.ctx().objects).toHaveLength(OBJECTS.length)); + expect(names(m.ctx().objects)).toEqual(names(OBJECTS)); + expect(objectCalls(m.server.calls)).toEqual(['object']); + + const noPackage = m.ctx().readObjectScope!({}); + expect(noPackage.objects).toBe(m.ctx().objects); + expect(noPackage.settled).toBe(true); + }); + + it('with the whole registry loaded, a scope is cut from it and asks for nothing', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + act(() => { + void m.ctx().objects; + }); + await waitFor(() => expect(m.ctx().getTypeStatus!('object')).toBe('ready')); + const scope = m.ctx().readObjectScope!({ packageId: CRM, names: ['sys_user', 'cloud_only'] }); + expect(scope.settled).toBe(true); + expect(names(scope.objects)).toEqual(['crm_account', 'crm_contact', 'sys_user']); + expect(scope.missing).toEqual(['cloud_only']); + expect(objectCalls(m.server.calls)).toEqual(['object']); + }); + + it('is dropped and refetched with the `object` type, never on a clock', async () => { + const m = mount(); + await waitFor(() => expect(m.server.calls).toContain('view')); + const read = () => m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }); + act(() => { + read(); + }); + await waitFor(() => expect(read().settled).toBe(true)); + m.server.calls.length = 0; + + // A by-name invalidation drops that definition; the next read asks it again. + act(() => m.ctx().invalidate('object', 'crm_case')); + expect(byName(read().objects, 'crm_case')).toBeUndefined(); + await waitFor(() => expect(byName(read().objects, 'crm_case')).toBeTruthy()); + expect(objectCalls(m.server.calls)).toEqual(['object/crm_case']); + + // `refresh` refetches the package list and the name in place: the items + // stay readable while the answers are in flight. + m.server.calls.length = 0; + m.server.holdByName = true; + let refreshed: Promise | null = null; + act(() => { + refreshed = m.ctx().refresh('object'); + }); + expect(byName(read().objects, 'crm_case')).toBeTruthy(); + expect(read().settled).toBe(true); + act(() => m.server.release()); + await act(async () => { + await refreshed; + }); + expect(objectCalls(m.server.calls).sort()).toEqual(['object', `object?package=${CRM}`, 'object/crm_case'].sort()); + m.server.holdByName = false; + + // A whole-type invalidation drops the scope; the next read starts again. + m.server.calls.length = 0; + act(() => m.ctx().invalidate('object')); + act(() => { + read(); + }); + await waitFor(() => expect(read().settled).toBe(true)); + expect(objectCalls(m.server.calls)).toEqual([`object?package=${CRM}`, 'object/crm_case']); + }); +}); diff --git a/packages/app-shell/src/providers/__tests__/MetadataProvider.requestBudget.test.tsx b/packages/app-shell/src/providers/__tests__/MetadataProvider.requestBudget.test.tsx index 7a79fdff45..e59517d8a0 100644 --- a/packages/app-shell/src/providers/__tests__/MetadataProvider.requestBudget.test.tsx +++ b/packages/app-shell/src/providers/__tests__/MetadataProvider.requestBudget.test.tsx @@ -46,9 +46,10 @@ function makeRecordingAdapter(calls: string[], mode: 'ok' | 'unauthorized') { } /** - * Mirrors the real first-render consumer (`useActionModal`, mounted by - * `GlobalActionRuntimeProvider`): it reads `objects` on EVERY render, and the - * `objects` getter reads both the `object` and the `view` type. + * A first-render consumer that reads `objects` on EVERY render, as + * `useActionModal` did until objectui#12811 (it now reads it only while an + * object's form modal is open). The `objects` getter reads both the `object` + * and the `view` type. */ function ObjectsConsumer() { const ctx = useMetadata(); diff --git a/packages/react/src/context/AppShellContext.tsx b/packages/react/src/context/AppShellContext.tsx index b7a2638902..4408b0387c 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -61,6 +61,24 @@ export interface MetadataContextValue extends MetadataCacheState { * context values in tests keep working; absent means "always ready". */ getTypeStatus?: (type: string) => MetadataTypeStatus; + /** + * The object definitions one app reads (objectui#12811): the objects of + * `packageId` (`GET /api/v1/meta/object?package=`), plus each of `names` + * that package does not hold, fetched by name. Each is shaped exactly as the + * same definition in `objects`. `missing` lists the `names` the server + * answered without a definition; `settled` is true once the package list and + * every name have been answered. + * + * With no `packageId` it is the whole registry, as `objects` is; `objects` + * keeps that meaning everywhere. Optional, as `getTypeStatus` is: a + * hand-rolled context without it has nothing narrower to offer, and its + * readers read `objects`. + */ + readObjectScope?: (scope: { packageId?: string; names?: readonly string[] }) => { + objects: any[]; + missing: readonly string[]; + settled: boolean; + }; } export const MetadataCtx = createContext(null); From faa9b5f1356eebac7189a5514791ed1ecb2cf1c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:27:17 +0000 Subject: [PATCH 2/7] perf(app-shell): the header's view-tab labels and the bell's activity feed read the app scope (objectui#12811) Measured live once the three objects readers had moved: AppHeader still loaded the whole registry through getItemsByType('object') in useServedViewItems and useObjectPresence (the bell's sys_activity presence). Both read the app's object scope now, until something loads the whole registry; readObjectScope also returns the definitions as served. The capability gate asks nothing itself: AppContent names the objects the app's own entries require, so the bell's cross-app roster no longer asks another app's objects one by one. The census pin reads the getItemsByType('object') spelling too. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- .../12811-scoped-object-metadata-app-shell.md | 6 +- .../12811-scoped-object-metadata-react.md | 5 +- packages/app-shell/src/console/AppContent.tsx | 23 +++- .../AppContent.objectScope-12811.test.tsx | 8 +- .../openPathObjectReaders-12811.test.ts | 40 ++++-- .../scopedRegistryReaders-12811.test.tsx | 114 ++++++++++++++++++ .../app-shell/src/hooks/useNavMemberGates.ts | 27 +++-- .../app-shell/src/hooks/useObjectPresence.ts | 17 ++- .../app-shell/src/hooks/useServedViewItems.ts | 14 ++- .../src/providers/MetadataProvider.tsx | 5 +- .../react/src/context/AppShellContext.tsx | 9 +- 11 files changed, 232 insertions(+), 36 deletions(-) create mode 100644 packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx diff --git a/.changeset/12811-scoped-object-metadata-app-shell.md b/.changeset/12811-scoped-object-metadata-app-shell.md index 1b5af865e3..70599cd9b5 100644 --- a/.changeset/12811-scoped-object-metadata-app-shell.md +++ b/.changeset/12811-scoped-object-metadata-app-shell.md @@ -8,8 +8,10 @@ Every console open used to request `GET /api/v1/meta/object` for every registere 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, an object a navigation entry's `requiresObject` names, and, -while the command palette is open, the navigation's objects. Until those answers land, the route shows +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: diff --git a/.changeset/12811-scoped-object-metadata-react.md b/.changeset/12811-scoped-object-metadata-react.md index 4b57bdcfd4..3d98c99de5 100644 --- a/.changeset/12811-scoped-object-metadata-react.md +++ b/.changeset/12811-scoped-object-metadata-react.md @@ -7,8 +7,9 @@ feat(react): `MetadataContextValue.readObjectScope`, the object definitions one 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`, `missing` (the names the server answered without a -definition) and `settled` (the package list and every name have answered). With no `packageId` it is +`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 diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 8795336826..8fefd40b53 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -286,8 +286,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // On the routes `APP_OBJECT_SCOPE_ROUTES` names, inside an app whose served // item carries its package, that is the app's object scope // (`readObjectScope`): the package's objects, plus by name the route's - // object and its lookup targets when the package does not hold them, and the - // navigation's objects while the command palette is open. Everywhere else it + // object and its lookup targets when the package does not hold them, the + // objects the app's navigation entries require, and the navigation's objects + // while the command palette is open. Everywhere else it // is the whole registry, read as `objects`, as before: a record surface // derives its related lists from every object that points at it, so a // record overlay (`?recordId=`, `?form=`) reads the whole registry, and so @@ -300,7 +301,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const scopeCandidate = !!scopeRoute && !!readObjectScope && !searchParamsHasRecordOverlay(location.search); let objectScope: ReturnType> | null = null; if (scopeCandidate && scopePackageId && readObjectScope) { - const names: string[] = []; + // The objects the app's own navigation entries require (`requiresObject`): + // the sidebar's capability gate answers from this scope. + const names: string[] = navigationRequiredObjectNames(activeApp?.navigation); if (scopeRoute.objectName) names.push(scopeRoute.objectName); if (new URLSearchParams(location.search).has(COMMAND_PALETTE_PARAM)) { names.push(...navigationObjectNames(activeApp?.navigation)); @@ -1662,6 +1665,20 @@ function lookupTargetNames(def: any): string[] { return [...out]; } +/** The objects an app's navigation entries require (`requiresObject`), at every group depth. */ +function navigationRequiredObjectNames(navigation: unknown): string[] { + const out: string[] = []; + const walk = (items: unknown) => { + if (!Array.isArray(items)) return; + for (const item of items as Array<{ requiresObject?: unknown; children?: unknown }>) { + if (typeof item?.requiresObject === 'string' && item.requiresObject) out.push(item.requiresObject); + walk(item?.children); + } + }; + walk(navigation); + return out; +} + /** The objects an app's navigation opens, at every group depth. */ function navigationObjectNames(navigation: unknown): string[] { const out: string[] = []; diff --git a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx index f6faca5416..6bc90cd24c 100644 --- a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx +++ b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx @@ -14,8 +14,8 @@ * * The harness is the REAL `MetadataProvider`, with a client double that records * each call as its URL would read, under the real `AppContent`. The chrome is a - * stand-in that does what the sidebar's capability gate does (one - * `requiresObject` entry), and `useActionModal` is mounted beside it, as + * stand-in that does what the sidebar's capability gate does for the app's + * one `requiresObject` entry, and `useActionModal` is mounted beside it, as * `ConsoleShell` mounts it. * * CONTROLs: an app item with no package id asks the whole registry, as before; @@ -99,6 +99,8 @@ const OBJECTS = [ const NAVIGATION = [ { id: 'n1', type: 'object', label: 'Accounts', objectName: 'crm_account' }, { id: 'n2', type: 'object', label: 'Cases', objectName: 'crm_case' }, + // An entry for an object this runtime may not register (a cloud-only one). + { id: 'n3', type: 'object', label: 'Cloud', objectName: 'cloud_only', requiresObject: 'cloud_only' }, ]; interface Server { @@ -208,7 +210,7 @@ describe('one console open asks for the app\'s objects, not the whole registry ( expect(objectCalls()).not.toContain('object'); expect(objectCalls()[0]).toBe(`object?package=${CRM}`); - // The route's lookup target outside the package, and the gate's `requiresObject` name. + // The route's lookup target outside the package, and the object the app's `requiresObject` entry names. expect([...objectCalls()].sort()).toEqual([`object?package=${CRM}`, 'object/cloud_only', 'object/sys_user'].sort()); // The route element and the chrome are handed the scope. expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact,sys_user'); diff --git a/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts index a8ddc3c971..c51d7004dd 100644 --- a/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts +++ b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -9,11 +9,13 @@ * so a reader that is mounted on that open and reads `objects` at render * brings the whole registry back onto it. * - * This file finds every reader of `objects` off the metadata context in the - * packages' and the console's source (a `{ objects }` destructured from - * `useMetadata()`, `useMetadata().objects`, or `.objects` on a binding of - * `useMetadata()`), and fails on a reader the table below does not classify, - * and on a row that names no reader. Each row says where the reader runs: + * This file finds every reader of the whole object list off the metadata + * context in the packages' and the console's source (a `{ objects }` + * destructured from `useMetadata()`, `useMetadata().objects`, `.objects` on a + * binding of `useMetadata()`, or `getItemsByType('object')`, which holds the + * same list as served), and fails on a reader the table below does not + * classify, and on a row that names no reader. Each row says where the reader + * runs: * * - `scope`: on the open path, it reads `readObjectScope`, and `objects` * only where no package scopes the read; @@ -23,8 +25,11 @@ * whole registry (the control: its own pins stay unchanged). * * Measured once on HotCRM (a Chromium open of `/apps/crm_enterprise/crm_account` - * with a stack-recording getter): the readers that touched `objects` were - * `AppContent`, `useActionModal` and `useNavMemberGates`. The `request` pin + * with a stack-recording getter, and the request initiators): the readers + * that touched the whole list were `AppContent`, `useActionModal` and + * `useNavMemberGates`, and, once those no longer loaded it first, + * `useServedViewItems` (the header's and the list's view-tab labels) and + * `useObjectPresence` (the bell's activity feed). The `request` pin * beside this file (`AppContent.objectScope-12811.test.tsx`) re-derives the * requests; this one re-derives who reads. */ @@ -50,6 +55,14 @@ const OBJECTS_READERS: Record = { kind: 'scope', why: "the capability gate reads the app's scope; with no app package (the home feeds) it reads objects", }, + 'packages/app-shell/src/hooks/useServedViewItems.ts': { + kind: 'scope', + why: "the served object documents behind the view-tab labels (AppHeader, ObjectView) are the app scope's, until something loads the whole registry", + }, + 'packages/app-shell/src/hooks/useObjectPresence.ts': { + kind: 'scope', + why: "an object's presence (the bell's sys_activity feed) is asked by name through the app scope, until something loads the whole registry", + }, 'packages/app-shell/src/hooks/useActionModal.tsx': { kind: 'action', why: "an object's form modal: the open waits for the whole list, whose subforms come from every object", @@ -91,6 +104,9 @@ function sourceFiles(dir: string, out: string[] = []): string[] { return out; } +const isObjectTypeArg = (node: ts.CallExpression): boolean => + node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === 'object'; + const isUseMetadataCall = (node: ts.Node | undefined): boolean => !!node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'useMetadata'; @@ -108,6 +124,7 @@ function readsObjects(path: string): boolean { for (const el of node.name.elements) { const key = el.propertyName ?? el.name; if (ts.isIdentifier(key) && key.text === 'objects') found = true; + if (ts.isIdentifier(key) && key.text === 'getItemsByType' && ts.isIdentifier(el.name)) bindings.add(`fn:${el.name.text}`); } } } @@ -119,6 +136,15 @@ function readsObjects(path: string): boolean { const target = node.expression; if (isUseMetadataCall(target) || (ts.isIdentifier(target) && bindings.has(target.text))) found = true; } + // `getItemsByType('object')`, called on a binding of `useMetadata()` or destructured from it. + if (ts.isCallExpression(node) && isObjectTypeArg(node)) { + const callee = node.expression; + if ( + (ts.isPropertyAccessExpression(callee) && callee.name.text === 'getItemsByType' && + (isUseMetadataCall(callee.expression) || (ts.isIdentifier(callee.expression) && bindings.has(callee.expression.text)))) || + (ts.isIdentifier(callee) && bindings.has(`fn:${callee.text}`)) + ) found = true; + } ts.forEachChild(node, access); }; access(file); diff --git a/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx b/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx new file mode 100644 index 0000000000..bf3ae29a8c --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12811 — two chrome readers of the object registry read the app's + * object scope. + * + * Measured on HotCRM once the open path's three `objects` readers had moved to + * the scope: the whole registry (`GET /api/v1/meta/object`) was still asked, + * from `AppHeader`, through `getItemsByType('object')` in `useServedViewItems` + * (which view-tab labels the server already translated) and in + * `useObjectPresence` (whether `sys_activity` exists, for the bell's activity + * feed). Inside an app whose served item names its package, and while nothing + * has asked for the whole registry, both now read the app's scope. + * + * CONTROL: outside an app (no package) both read the whole registry, as + * before; and once anything has loaded the whole registry, both read it. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MetadataProvider, useMetadata } from '../../providers/MetadataProvider'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; +import { useServedViewItems, isServedView } from '../useServedViewItems'; +import { useObjectPresence } from '../useObjectPresence'; + +const CRM = 'app.crm'; +const OBJECTS = [ + { name: 'crm_account', _packageId: CRM, listViews: { 'crm_account.mine': { label: 'Mine', type: 'grid' } } }, + { name: 'sys_activity', _packageId: 'plugin.audit' }, +]; + +function makeAdapter(calls: string[]) { + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: (type: string, options?: { packageId?: string }) => { + calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); + const all = type === 'object' ? OBJECTS : []; + const items = options?.packageId ? all.filter((o) => o._packageId === options.packageId) : all; + return Promise.resolve({ type, items: structuredClone(items) }); + }, + getItem: (type: string, name: string) => { + calls.push(`${type}/${name}`); + const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + return item ? Promise.resolve({ item: structuredClone(item) }) : Promise.reject(new Error('404')); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +let latest: { + served: ReturnType; + presence: ReturnType; + ctx: ReturnType; +} | null = null; +function Probe() { + latest = { served: useServedViewItems(), presence: useObjectPresence('sys_activity'), ctx: useMetadata() }; + return null; +} + +function mount(app: Record, calls: string[], extra?: ReactNode) { + render( + + + + {extra} + + , + ); +} + +const objectCalls = (calls: string[]) => calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); + +afterEach(() => { + cleanup(); + latest = null; +}); + +describe('the chrome readers of the object registry read the app scope (objectui#12811)', () => { + it("inside an app with a package: the served documents are the scope's, and presence is asked by name", async () => { + const calls: string[] = []; + mount({ name: 'crm', _packageId: CRM }, calls); + await waitFor(() => expect(latest!.presence.settled).toBe(true)); + await waitFor(() => expect(isServedView(latest!.served, 'crm_account', 'crm_account.mine')).toBe(true)); + + expect(latest!.presence.presence).toBe('present'); + expect(objectCalls(calls)).not.toContain('object'); + expect([...objectCalls(calls)].sort()).toEqual([`object?package=${CRM}`, 'object/sys_activity'].sort()); + }); + + it('CONTROL: outside an app (no package), both read the whole registry, as before', async () => { + const calls: string[] = []; + mount({}, calls); + await waitFor(() => expect(latest!.presence.settled).toBe(true)); + expect(latest!.presence.presence).toBe('present'); + expect(isServedView(latest!.served, 'crm_account', 'crm_account.mine')).toBe(true); + expect(objectCalls(calls)).toEqual(['object']); + }); + + it('CONTROL: once the whole registry is loaded, both read it', async () => { + const calls: string[] = []; + mount({ name: 'crm', _packageId: CRM }, calls); + await waitFor(() => expect(latest!.presence.settled).toBe(true)); + act(() => { + void latest!.ctx.objects; + }); + await waitFor(() => expect(latest!.ctx.getTypeStatus!('object')).toBe('ready')); + expect(latest!.served.objects).toHaveLength(OBJECTS.length); + expect(latest!.presence.presence).toBe('present'); + }); +}); diff --git a/packages/app-shell/src/hooks/useNavMemberGates.ts b/packages/app-shell/src/hooks/useNavMemberGates.ts index 99cfcb64b5..10ba80aff9 100644 --- a/packages/app-shell/src/hooks/useNavMemberGates.ts +++ b/packages/app-shell/src/hooks/useNavMemberGates.ts @@ -110,16 +110,20 @@ export function useNavMemberGates(): NavMemberGates { // Runtime capability gate: hide nav items targeting objects/services // not registered in this runtime (e.g. cloud-only `sys_app`). // - // objectui#12811 — inside an app whose served item names its package, the - // gate reads that app's object scope, not the whole registry: an object the - // package holds is registered, and one outside it is asked for by name when - // an entry names it (`requiresObject`), and is registered when the server - // answers with its definition. Until that answer, it reads as registered, - // as every object did while the whole list loaded. Outside an app (the home - // page's feeds) there is no package, and the gate reads the whole registry. + // objectui#12811 — inside an app whose served item names its package, and + // while nothing has asked for the whole registry, the gate reads that app's + // object scope (`readObjectScope`): an object the scope holds is registered, + // one the server answered without a definition is not, and any other reads + // as registered, as every object did while the whole list loaded. The gate + // asks for nothing itself: `AppContent` names the objects the app's own + // entries require, so the sidebar's answer is exact, while the bell's + // cross-app roster does not ask another app's objects one by one. Outside an + // app (the home page's feeds) there is no package, and the gate reads the + // whole registry, as it does once anything has loaded it. const metadata = useMetadata(); const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; - const readObjectScope = packageId ? metadata.readObjectScope : undefined; + const readObjectScope = + packageId && metadata.getTypeStatus?.('object') === 'idle' ? metadata.readObjectScope : undefined; const scope = readObjectScope ? readObjectScope({ packageId }) : null; const metadataObjects = scope ? scope.objects : metadata.objects; const missingObjects = scope ? scope.missing : NO_MISSING; @@ -135,14 +139,11 @@ export function useNavMemberGates(): NavMemberGates { if (registeredObjectNames.size === 0) return true; return registeredObjectNames.has(name); } - if (registeredObjectNames.has(name)) return true; - if (missingObjectNames.has(name)) return false; - readObjectScope({ packageId, names: [name] }); - return true; + return registeredObjectNames.has(name) || !missingObjectNames.has(name); } return true; }, - [registeredObjectNames, missingObjectNames, readObjectScope, packageId], + [registeredObjectNames, missingObjectNames, readObjectScope], ); return { evaluateVisibility: evalVis, checkPermission: checkPerm, checkCapability: checkCap, can }; diff --git a/packages/app-shell/src/hooks/useObjectPresence.ts b/packages/app-shell/src/hooks/useObjectPresence.ts index 2cea7d1c00..7eb0a8b3aa 100644 --- a/packages/app-shell/src/hooks/useObjectPresence.ts +++ b/packages/app-shell/src/hooks/useObjectPresence.ts @@ -54,6 +54,7 @@ * hook adds a request; the point is to remove one. */ import { useMetadata, type MetadataTypeStatus } from '@object-ui/react'; +import { useExpressionContext } from '../providers/ExpressionProvider.js'; /** What the metadata registry can say about one object name. */ export type ObjectPresence = 'present' | 'absent' | 'unknown'; @@ -93,7 +94,21 @@ export function objectPresence( /** {@link objectPresence} bound to the shell's metadata registry. */ export function useObjectPresence(name: string): ObjectPresenceReading { - const { getItemsByType, getTypeStatus } = useMetadata(); + const { getItemsByType, getTypeStatus, readObjectScope } = useMetadata(); + // objectui#12811 — inside an app whose served item names its package, and + // while nothing has asked for the whole registry, the object is asked for by + // name through the app's object scope, rather than by loading every object. + const { app } = useExpressionContext(); + const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; + if (packageId && readObjectScope && getTypeStatus?.('object') === 'idle') { + const scope = readObjectScope({ packageId, names: [name] }); + const presence: ObjectPresence = !scope.settled + ? 'unknown' + : scope.objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === name) + ? 'present' + : 'absent'; + return { presence, settled: scope.settled }; + } // Reading the items is also what ENSURES the type is fetched (MetadataProvider // `readType`), so a surface that only ever asks this question still gets an // answer instead of waiting on somebody else to populate the cache. diff --git a/packages/app-shell/src/hooks/useServedViewItems.ts b/packages/app-shell/src/hooks/useServedViewItems.ts index 3eb10f0c9c..f7820b55f1 100644 --- a/packages/app-shell/src/hooks/useServedViewItems.ts +++ b/packages/app-shell/src/hooks/useServedViewItems.ts @@ -60,6 +60,7 @@ */ import { useMetadata } from '@object-ui/react'; +import { useExpressionContext } from '../providers/ExpressionProvider.js'; /** One identity for "nothing was served", so a dependency list on it holds. */ const NONE_SERVED: readonly unknown[] = Object.freeze([]); @@ -113,7 +114,18 @@ function payloadOf(items: unknown): readonly unknown[] { */ export function useServedViewItems(): ServedViewReads { const metadata = useMetadata(); - return servedViewReads(payloadOf(metadata.getItemsByType('view')), payloadOf(metadata.getItemsByType('object'))); + // objectui#12811 — inside an app whose served item names its package, and + // while nothing has asked for the whole registry, the served object + // documents are the app's object scope's, as served (the route's object is + // in it: `AppContent` names it). Reading `getItemsByType('object')` here + // loaded the whole registry on every open of an object list. + const { app } = useExpressionContext(); + const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; + const objects = + packageId && metadata.readObjectScope && metadata.getTypeStatus?.('object') === 'idle' + ? metadata.readObjectScope({ packageId }).served + : metadata.getItemsByType('object'); + return servedViewReads(payloadOf(metadata.getItemsByType('view')), payloadOf(objects)); } function isRecord(value: unknown): value is Record { diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index 5da2640573..7cfe975614 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -1230,8 +1230,10 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: const packageId = scope.packageId; if (!packageId) { const status = getEntry('object').status; + const served = readType('object'); return { - objects: composeObjects(readType('object'), readType('view')) as any[], + objects: composeObjects(served, readType('view')) as any[], + served, missing: NO_NAMES, settled: status === 'ready' || status === 'error', }; @@ -1280,6 +1282,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: } return { objects: composeObjects(entry.items, readType('view')) as any[], + served: entry.items, missing: entry.missing as readonly string[], settled: entry.answered && namesSettled, }; diff --git a/packages/react/src/context/AppShellContext.tsx b/packages/react/src/context/AppShellContext.tsx index 4408b0387c..47a1a7e391 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -65,9 +65,11 @@ export interface MetadataContextValue extends MetadataCacheState { * The object definitions one app reads (objectui#12811): the objects of * `packageId` (`GET /api/v1/meta/object?package=`), plus each of `names` * that package does not hold, fetched by name. Each is shaped exactly as the - * same definition in `objects`. `missing` lists the `names` the server - * answered without a definition; `settled` is true once the package list and - * every name have been answered. + * same definition in `objects`; `served` holds the same definitions as the + * server served them, before views are merged (as `getItemsByType('object')` + * holds the whole list). `missing` lists the `names` the server answered + * without a definition; `settled` is true once the package list and every + * name have been answered. * * With no `packageId` it is the whole registry, as `objects` is; `objects` * keeps that meaning everywhere. Optional, as `getTypeStatus` is: a @@ -76,6 +78,7 @@ export interface MetadataContextValue extends MetadataCacheState { */ readObjectScope?: (scope: { packageId?: string; names?: readonly string[] }) => { objects: any[]; + served: any[]; missing: readonly string[]; settled: boolean; }; From 8843c8f52645f8bebd9bf3b1e17755a9b67385c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:30:51 +0000 Subject: [PATCH 3/7] chore(app-shell): keep the scope route list module-private, and the readers pin's probe inside its mount (objectui#12811) Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- packages/app-shell/src/console/AppContent.tsx | 2 +- .../scopedRegistryReaders-12811.test.tsx | 49 +++++++++---------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 8fefd40b53..7b1c5a7f4b 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -1622,7 +1622,7 @@ const NO_OBJECTS: any[] = []; * matched against that table, so a more specific route (`search`, * `create-app`, a host route) is never taken for `:objectName`. */ -export const APP_OBJECT_SCOPE_ROUTES: readonly string[] = ['/', ':objectName', ':objectName/view/:viewId']; +const APP_OBJECT_SCOPE_ROUTES: readonly string[] = ['/', ':objectName', ':objectName/view/:viewId']; function matchObjectScopeRoute( subPath: string, diff --git a/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx b/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx index bf3ae29a8c..999ac78078 100644 --- a/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx @@ -51,17 +51,18 @@ function makeAdapter(calls: string[]) { } as unknown as Parameters[0]['adapter']; } -let latest: { +type Reading = { served: ReturnType; presence: ReturnType; ctx: ReturnType; -} | null = null; -function Probe() { - latest = { served: useServedViewItems(), presence: useObjectPresence('sys_activity'), ctx: useMetadata() }; - return null; -} +}; -function mount(app: Record, calls: string[], extra?: ReactNode) { +function mount(app: Record, calls: string[], extra?: ReactNode): () => Reading { + let latest: Reading | null = null; + function Probe() { + latest = { served: useServedViewItems(), presence: useObjectPresence('sys_activity'), ctx: useMetadata() }; + return null; + } render( @@ -70,45 +71,43 @@ function mount(app: Record, calls: string[], extra?: ReactNode) , ); + return () => latest as unknown as Reading; } const objectCalls = (calls: string[]) => calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); -afterEach(() => { - cleanup(); - latest = null; -}); +afterEach(() => cleanup()); describe('the chrome readers of the object registry read the app scope (objectui#12811)', () => { it("inside an app with a package: the served documents are the scope's, and presence is asked by name", async () => { const calls: string[] = []; - mount({ name: 'crm', _packageId: CRM }, calls); - await waitFor(() => expect(latest!.presence.settled).toBe(true)); - await waitFor(() => expect(isServedView(latest!.served, 'crm_account', 'crm_account.mine')).toBe(true)); + const read = mount({ name: 'crm', _packageId: CRM }, calls); + await waitFor(() => expect(read().presence.settled).toBe(true)); + await waitFor(() => expect(isServedView(read().served, 'crm_account', 'crm_account.mine')).toBe(true)); - expect(latest!.presence.presence).toBe('present'); + expect(read().presence.presence).toBe('present'); expect(objectCalls(calls)).not.toContain('object'); expect([...objectCalls(calls)].sort()).toEqual([`object?package=${CRM}`, 'object/sys_activity'].sort()); }); it('CONTROL: outside an app (no package), both read the whole registry, as before', async () => { const calls: string[] = []; - mount({}, calls); - await waitFor(() => expect(latest!.presence.settled).toBe(true)); - expect(latest!.presence.presence).toBe('present'); - expect(isServedView(latest!.served, 'crm_account', 'crm_account.mine')).toBe(true); + const read = mount({}, calls); + await waitFor(() => expect(read().presence.settled).toBe(true)); + expect(read().presence.presence).toBe('present'); + expect(isServedView(read().served, 'crm_account', 'crm_account.mine')).toBe(true); expect(objectCalls(calls)).toEqual(['object']); }); it('CONTROL: once the whole registry is loaded, both read it', async () => { const calls: string[] = []; - mount({ name: 'crm', _packageId: CRM }, calls); - await waitFor(() => expect(latest!.presence.settled).toBe(true)); + const read = mount({ name: 'crm', _packageId: CRM }, calls); + await waitFor(() => expect(read().presence.settled).toBe(true)); act(() => { - void latest!.ctx.objects; + void read().ctx.objects; }); - await waitFor(() => expect(latest!.ctx.getTypeStatus!('object')).toBe('ready')); - expect(latest!.served.objects).toHaveLength(OBJECTS.length); - expect(latest!.presence.presence).toBe('present'); + await waitFor(() => expect(read().ctx.getTypeStatus!('object')).toBe('ready')); + expect(read().served.objects).toHaveLength(OBJECTS.length); + expect(read().presence.presence).toBe('present'); }); }); From c178220ac7e7e097c7a9d59ad06a4a3db2f8d424 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:02:41 +0000 Subject: [PATCH 4/7] perf(app-shell): the console root starts the app's object scope beside the permission read (objectui#12811) Measured on HotCRM behind a 50 ms round trip, production builds: the whole object list used to start at the console root (useActionModal), beside me/permissions; the scope AppContent asked for started only after it, so the splash on the branch held about 140 ms longer than on main. The scope question moves to console/appObjectScope.ts, which AppContent and a root hook in ConsoleShell's action runtime both ask, so the package list starts as soon as the app list names the app. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- packages/app-shell/src/console/AppContent.tsx | 134 ++-------------- .../app-shell/src/console/ConsoleShell.tsx | 6 + .../__tests__/appObjectScope-12811.test.tsx | 93 +++++++++++ .../app-shell/src/console/appObjectScope.ts | 148 ++++++++++++++++++ 4 files changed, 262 insertions(+), 119 deletions(-) create mode 100644 packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx create mode 100644 packages/app-shell/src/console/appObjectScope.ts diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 7b1c5a7f4b..eecc6dd02f 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -8,7 +8,7 @@ * /organizations) is provided by `createConsole` from @object-ui/app-shell. */ -import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams, matchRoutes } from 'react-router-dom'; +import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams } from 'react-router-dom'; import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo, type ReactNode } from 'react'; import { useAssistant } from '../assistant/assistantBus.js'; import { ModalForm } from '@object-ui/plugin-form'; @@ -34,7 +34,7 @@ import { useHomePath } from '../hooks/useHomePath.js'; import { useSignedInUserLocale } from '../hooks/useUserLocale.js'; import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view'; -import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_DRAWER_PARAM, COMMAND_PALETTE_PARAM } from '../urlParams.js'; +import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js'; import { appRouteSegment, matchAppBySegment } from '../utils/appRoute.js'; import { resolveHref, type NavTemplateContext } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; @@ -54,7 +54,8 @@ import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js'; import { appServesNoNavigation } from './appServesNoNavigation.js'; import { useNavMemberGates, navEntriesShownToMember } from '../hooks/useNavMemberGates.js'; import { resolveSetupAppPath } from './ConsoleShell.js'; -import { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.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 @@ -282,43 +283,17 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = 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. - // On the routes `APP_OBJECT_SCOPE_ROUTES` names, inside an app whose served - // item carries its package, that is the app's object scope - // (`readObjectScope`): the package's objects, plus by name the route's - // object and its lookup targets when the package does not hold them, the - // objects the app's navigation entries require, and the navigation's objects - // while the command palette is open. Everywhere else it - // is the whole registry, read as `objects`, as before: a record surface - // derives its related lists from every object that points at it, so a - // record overlay (`?recordId=`, `?form=`) reads the whole registry, and so - // does a list whose views open records in an in-page overlay the URL does - // not carry (`modal`, `split`, `popover`). An app item without a package id - // has no package to scope by. - const scopeRoute = matchObjectScopeRoute(appSubPath, hostRoutePaths); - const scopePackageId = - typeof activeApp?._packageId === 'string' && activeApp._packageId ? activeApp._packageId : undefined; - const scopeCandidate = !!scopeRoute && !!readObjectScope && !searchParamsHasRecordOverlay(location.search); - let objectScope: ReturnType> | null = null; - if (scopeCandidate && scopePackageId && readObjectScope) { - // The objects the app's own navigation entries require (`requiresObject`): - // the sidebar's capability gate answers from this scope. - const names: string[] = navigationRequiredObjectNames(activeApp?.navigation); - if (scopeRoute.objectName) names.push(scopeRoute.objectName); - if (new URLSearchParams(location.search).has(COMMAND_PALETTE_PARAM)) { - names.push(...navigationObjectNames(activeApp?.navigation)); - } - const first = readObjectScope({ packageId: scopePackageId, names }); - const routeDef = scopeRoute.objectName - ? first.objects.find((o: any) => o?.name === scopeRoute.objectName) - : undefined; - if (!routeDef || !opensRecordsInPageOverlay(routeDef)) { - const lookups = routeDef ? lookupTargetNames(routeDef) : []; - objectScope = lookups.length > 0 - ? readObjectScope({ packageId: scopePackageId, names: [...names, ...lookups] }) - : first; - } - } + // 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 } = 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); @@ -1614,85 +1589,6 @@ function searchParamsHasRecordForm(search: string): boolean { const NO_OBJECTS: any[] = []; -/** - * objectui#12811 — the routes under `/apps/:appName` whose element, and the - * chrome around it, read the app's object scope instead of the whole - * registry: the app's root (its landing reads only the navigation's gates) - * and an object's list. Paths as `APP_ROUTE_METADATA_READS` spells them, and - * matched against that table, so a more specific route (`search`, - * `create-app`, a host route) is never taken for `:objectName`. - */ -const APP_OBJECT_SCOPE_ROUTES: readonly string[] = ['/', ':objectName', ':objectName/view/:viewId']; - -function matchObjectScopeRoute( - subPath: string, - hostPaths: readonly string[], -): { objectName?: string } | null { - const routes = [...APP_ROUTE_METADATA_READS.map(({ path }) => ({ path })), ...hostPaths.map((path) => ({ path }))]; - const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`); - const match = matches?.[matches.length - 1]; - if (!match || !APP_OBJECT_SCOPE_ROUTES.includes(match.route.path)) return null; - return { objectName: match.params.objectName }; -} - -/** A record overlay the URL carries: the detail drawer, or the record form. */ -function searchParamsHasRecordOverlay(search: string): boolean { - return new URLSearchParams(search).has(RECORD_DRAWER_PARAM) || searchParamsHasRecordForm(search); -} - -const IN_PAGE_RECORD_OVERLAYS = new Set(['modal', 'split', 'popover']); - -/** - * Whether a list of this object opens a record in an overlay the URL does not - * carry: `ObjectView` mounts `RecordDetailView` inside it with the list's - * objects, so such a list reads the whole registry. - */ -function opensRecordsInPageOverlay(def: any): boolean { - const views = [def?.list, ...Object.values((def?.listViews ?? {}) as Record)]; - return views.some((view) => IN_PAGE_RECORD_OVERLAYS.has(view?.navigation?.mode)); -} - -/** The objects an object's lookup and master-detail fields point at. */ -function lookupTargetNames(def: any): string[] { - const fields = def?.fields; - const defs: any[] = Array.isArray(fields) ? fields : Object.values((fields ?? {}) as Record); - const out = new Set(); - for (const field of defs) { - if ((field?.type === 'lookup' || field?.type === 'master_detail') && typeof field.reference === 'string' && field.reference) { - out.add(field.reference); - } - } - return [...out]; -} - -/** The objects an app's navigation entries require (`requiresObject`), at every group depth. */ -function navigationRequiredObjectNames(navigation: unknown): string[] { - const out: string[] = []; - const walk = (items: unknown) => { - if (!Array.isArray(items)) return; - for (const item of items as Array<{ requiresObject?: unknown; children?: unknown }>) { - if (typeof item?.requiresObject === 'string' && item.requiresObject) out.push(item.requiresObject); - walk(item?.children); - } - }; - walk(navigation); - return out; -} - -/** The objects an app's navigation opens, at every group depth. */ -function navigationObjectNames(navigation: unknown): string[] { - const out: string[] = []; - const walk = (items: unknown) => { - if (!Array.isArray(items)) return; - for (const item of items as Array<{ type?: string; objectName?: unknown; children?: unknown }>) { - if (item?.type === 'object' && typeof item.objectName === 'string' && item.objectName) out.push(item.objectName); - walk(item?.children); - } - }; - walk(navigation); - return out; -} - /** * objectui#12809 — whether every bucket in `readsKey` (comma-joined) has * answered: ready, or failed (a failure is an answer the route renders). diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 172463c264..abdf690511 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -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'; @@ -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 ( ({ + meta: { + getItems: (type: string, options?: { packageId?: string }) => { + calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); + const all = type === 'object' ? OBJECTS : type === 'app' ? apps : []; + const items = options?.packageId ? all.filter((o: any) => o._packageId === options.packageId) : all; + return Promise.resolve({ type, items: structuredClone(items) }); + }, + getItem: (type: string, name: string) => { + calls.push(`${type}/${name}`); + const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + return item ? Promise.resolve({ item: structuredClone(item) }) : Promise.reject(new Error('404')); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function Prefetch() { + useAppObjectScopePrefetch(); + return null; +} + +function mount(url: string, apps: unknown[]): string[] { + // Each mount reads its own app list, not the one a previous mount cached. + sessionStorage.clear(); + const calls: string[] = []; + render( + + + + + , + ); + return calls; +} + +const objectCalls = (calls: string[]) => calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); +const APPS = [{ name: 'crm', _packageId: CRM, navigation: [] }]; + +afterEach(() => cleanup()); + +describe('the console root starts the app object scope (objectui#12811)', () => { + it("asks the app's package list, then the route object's lookup targets by name, never the whole list", async () => { + const calls = mount('/apps/crm/crm_account', APPS); + await waitFor(() => expect(calls).toContain('object/sys_user')); + expect(objectCalls(calls)).toEqual([`object?package=${CRM}`, 'object/sys_user']); + }); + + it('CONTROL: outside an app, on a record 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?recordId=r1', APPS); + const noPackage = mount('/apps/crm/crm_account', [{ name: 'crm', navigation: [] }]); + await waitFor(() => expect(overlay).toContain('view')); + await waitFor(() => expect(noPackage).toContain('view')); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(objectCalls(outside)).toEqual([]); + expect(objectCalls(overlay)).toEqual([]); + expect(objectCalls(noPackage)).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/console/appObjectScope.ts b/packages/app-shell/src/console/appObjectScope.ts new file mode 100644 index 0000000000..8d62da20bf --- /dev/null +++ b/packages/app-shell/src/console/appObjectScope.ts @@ -0,0 +1,148 @@ +/** + * Which objects an app route reads (objectui#12811). + * + * Reading `useMetadata().objects` loads the whole object registry + * (`GET /api/v1/meta/object`). On the routes {@link APP_OBJECT_SCOPE_ROUTES} + * names, inside an app whose served item carries its package, the route and + * the chrome around it read the app's object scope instead + * (`MetadataContextValue.readObjectScope`): the package's objects, plus by + * name the route's object and its lookup targets when the package does not + * hold them, the objects the app's navigation entries require + * (`requiresObject`), and the navigation's objects while the command palette + * is open. + * + * Everywhere else the route reads the whole registry, as before. A record + * surface derives its related lists from every object that points at its + * object, in any package, so a record overlay (`?recordId=`, `?form=`) reads + * the whole registry, and so does a list whose views open records in an + * in-page overlay the URL does not carry (`modal`, `split`, `popover`). An app + * item without a package id has no package to scope by. + * + * Two readers ask the same question with the same answer: `AppContent`, which + * renders the route, and the console root (`ConsoleShell`), which asks it as + * soon as the app list lands, so the scope loads beside the session's + * permission read rather than after it, as the whole list used to. + */ + +import { matchRoutes, useLocation } from 'react-router-dom'; +import { useMetadata, type MetadataContextValue } from '@object-ui/react'; +import { APP_ROUTE_METADATA_READS } from './appRouteMetadataReads.js'; +import { matchAppBySegment } from '../utils/appRoute.js'; +import { COMMAND_PALETTE_PARAM, RECORD_DRAWER_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_PARAM } from '../urlParams.js'; + +type ReadObjectScope = NonNullable; +export type AppObjectScope = ReturnType; + +/** + * The routes under `/apps/:appName` whose element, and the chrome around it, + * read the app's object scope: the app's root (its landing reads only the + * navigation's gates) and an object's list. Paths as `APP_ROUTE_METADATA_READS` + * spells them, and matched against that table, so a more specific route + * (`search`, `create-app`, a host route) is never taken for `:objectName`. + */ +export const APP_OBJECT_SCOPE_ROUTES: readonly string[] = ['/', ':objectName', ':objectName/view/:viewId']; + +function matchObjectScopeRoute(subPath: string, hostPaths: readonly string[]): { objectName?: string } | null { + const routes = [...APP_ROUTE_METADATA_READS.map(({ path }) => ({ path })), ...hostPaths.map((path) => ({ path }))]; + const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`); + const match = matches?.[matches.length - 1]; + if (!match || !APP_OBJECT_SCOPE_ROUTES.includes(match.route.path)) return null; + return { objectName: match.params.objectName }; +} + +/** A record overlay the URL carries: the detail drawer, or the record form. */ +function hasRecordOverlay(search: string): boolean { + const sp = new URLSearchParams(search); + return sp.has(RECORD_DRAWER_PARAM) || sp.has(RECORD_FORM_PARAM) || sp.has(RECORD_FORM_OBJECT_PARAM); +} + +const IN_PAGE_RECORD_OVERLAYS = new Set(['modal', 'split', 'popover']); + +/** + * Whether a list of this object opens a record in an overlay the URL does not + * carry: `ObjectView` mounts `RecordDetailView` inside it with the list's + * objects, so such a list reads the whole registry. + */ +function opensRecordsInPageOverlay(def: any): boolean { + const views = [def?.list, ...Object.values((def?.listViews ?? {}) as Record)]; + return views.some((view) => IN_PAGE_RECORD_OVERLAYS.has(view?.navigation?.mode)); +} + +/** The objects an object's lookup and master-detail fields point at. */ +function lookupTargetNames(def: any): string[] { + const fields = def?.fields; + const defs: any[] = Array.isArray(fields) ? fields : Object.values((fields ?? {}) as Record); + const out = new Set(); + for (const field of defs) { + if ((field?.type === 'lookup' || field?.type === 'master_detail') && typeof field.reference === 'string' && field.reference) { + out.add(field.reference); + } + } + return [...out]; +} + +/** The names a navigation tree carries under `key` on its entries, at every group depth. */ +function navigationNames(navigation: unknown, pick: (item: Record) => unknown): string[] { + const out: string[] = []; + const walk = (items: unknown) => { + if (!Array.isArray(items)) return; + for (const item of items as Array>) { + const name = item ? pick(item) : undefined; + if (typeof name === 'string' && name) out.push(name); + walk(item?.children); + } + }; + walk(navigation); + return out; +} + +export interface AppObjectScopeRead { + /** The route reads a scope when its app names a package: an app route on the scope list, no record overlay. */ + candidate: boolean; + /** The app's object scope, or `null` when this route reads the whole registry. */ + scope: AppObjectScope | null; +} + +/** + * The object scope the route at `subPath` (below `/apps/:appName`) reads, in + * `app`. Reading it starts what it needs (see `readObjectScope`). + */ +export function readAppObjectScope( + readObjectScope: ReadObjectScope | undefined, + app: Record | undefined, + subPath: string, + search: string, + hostPaths: readonly string[] = [], +): AppObjectScopeRead { + const route = matchObjectScopeRoute(subPath, hostPaths); + const candidate = !!route && !!readObjectScope && !hasRecordOverlay(search); + const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; + if (!candidate || !packageId || !readObjectScope || !route) return { candidate, scope: null }; + // The objects the app's own navigation entries require: the sidebar's + // capability gate answers from this scope. + const names = navigationNames(app?.navigation, (item) => item.requiresObject); + if (route.objectName) names.push(route.objectName); + if (new URLSearchParams(search).has(COMMAND_PALETTE_PARAM)) { + names.push(...navigationNames(app?.navigation, (item) => (item.type === 'object' ? item.objectName : undefined))); + } + const first = readObjectScope({ packageId, names }); + const routeDef = route.objectName ? first.objects.find((o: any) => o?.name === route.objectName) : undefined; + if (routeDef && opensRecordsInPageOverlay(routeDef)) return { candidate, scope: null }; + const lookups = routeDef ? lookupTargetNames(routeDef) : []; + return { candidate, scope: lookups.length > 0 ? readObjectScope({ packageId, names: [...names, ...lookups] }) : first }; +} + +/** + * 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. + */ +export function useAppObjectScopePrefetch(): void { + const { apps, readObjectScope } = useMetadata(); + 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); +} From 6229101b9ce47e75911b058df2891d38e547a238 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:33:29 +0000 Subject: [PATCH 5/7] test(app-shell): the session-gate pin no longer expects the whole object list at boot (objectui#12811) The pin's third request was the whole registry, which useActionModal loaded on every open. Its purpose stands: no metadata request before auth, and each eager request once after it. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- .../__tests__/ConnectedShell.sessionGate.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx b/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx index 56647ddb41..087e3f0cce 100644 --- a/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx +++ b/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx @@ -12,7 +12,12 @@ * Both directions are pinned, because the fix is worth nothing if it also * stops the signed-in console from loading: * - auth pending → zero metadata requests; - * - auth resolved → the same three requests, once each. + * - auth resolved → the eager requests, once each. + * + * objectui#12811: the eager requests are `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. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -104,7 +109,7 @@ describe('ConnectedShell session gate (objectui#4042)', () => { await waitFor(() => expect(metaCalls).toContain('app')); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(metaCalls.filter((c) => c === 'object')).toHaveLength(1); + expect(metaCalls.filter((c) => c === 'object')).toHaveLength(0); expect(metaCalls.filter((c) => c === 'view')).toHaveLength(1); expect(metaCalls.filter((c) => c === 'app')).toHaveLength(1); expect(getByTestId('route-content')).toBeTruthy(); From d0c83c5f7a66b4a794853c344441058981bd94ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:35:17 +0000 Subject: [PATCH 6/7] perf(app-shell): an app whose package holds no objects reads the whole registry (objectui#12811) Measured on HotCRM: Setup's package holds no objects, and its navigation requires 15 objects of other packages. Its scope asked 11 of them by name, and its landing loaded the whole registry anyway: 47 API calls against 35 on main. The package list is read first; an app it shows to own no objects reads the whole registry, and no name is asked. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- .changeset/12811-scoped-object-metadata-app-shell.md | 4 +++- .../__tests__/AppContent.objectScope-12811.test.tsx | 11 ++++++++++- packages/app-shell/src/console/appObjectScope.ts | 11 ++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.changeset/12811-scoped-object-metadata-app-shell.md b/.changeset/12811-scoped-object-metadata-app-shell.md index 70599cd9b5..b76290f947 100644 --- a/.changeset/12811-scoped-object-metadata-app-shell.md +++ b/.changeset/12811-scoped-object-metadata-app-shell.md @@ -16,7 +16,9 @@ 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, and every route outside an app (the home page). +- 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`, diff --git a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx index 6bc90cd24c..37a43b71d1 100644 --- a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx +++ b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx @@ -18,7 +18,8 @@ * one `requiresObject` entry, and `useActionModal` is mounted beside it, as * `ConsoleShell` mounts it. * - * CONTROLs: an app item with no package id asks the whole registry, as before; + * CONTROLs: an app item with no package id, or whose package holds no objects + * of its own (a platform app such as Setup), asks the whole registry, as before; * a record overlay (`?recordId=`) reads the whole registry, because a record's * related lists come from every object that points at it. */ @@ -227,6 +228,14 @@ describe('one console open asks for the app\'s objects, not the whole registry ( expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact,crm_case,sys_user'); }); + it('CONTROL: an app whose package holds no objects of its own reads the whole registry, and asks none by name', async () => { + server.apps = [{ name: 'crm', label: 'Setup-like', _packageId: 'app.platform', navigation: NAVIGATION }]; + boot('/apps/crm/crm_account'); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case')); + + expect(objectCalls()).toEqual(['object?package=app.platform', 'object']); + }); + it('CONTROL: a record overlay reads the whole registry (a record\'s related lists come from every object)', async () => { boot('/apps/crm/crm_account?recordId=r1'); await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); diff --git a/packages/app-shell/src/console/appObjectScope.ts b/packages/app-shell/src/console/appObjectScope.ts index 8d62da20bf..94d1866311 100644 --- a/packages/app-shell/src/console/appObjectScope.ts +++ b/packages/app-shell/src/console/appObjectScope.ts @@ -16,7 +16,11 @@ * object, in any package, so a record overlay (`?recordId=`, `?form=`) reads * the whole registry, and so does a list whose views open records in an * in-page overlay the URL does not carry (`modal`, `split`, `popover`). An app - * item without a package id has no package to scope by. + * item without a package id has no package to scope by, and neither has an app + * whose package holds no objects of its own (a platform app such as Setup, + * whose every entry opens another package's object): asking each of those by + * name costs a request per object, measured on HotCRM, and its landing loads + * the whole registry anyway. * * Two readers ask the same question with the same answer: `AppContent`, which * renders the route, and the console root (`ConsoleShell`), which asks it as @@ -118,6 +122,11 @@ export function readAppObjectScope( const candidate = !!route && !!readObjectScope && !hasRecordOverlay(search); const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; if (!candidate || !packageId || !readObjectScope || !route) return { candidate, scope: null }; + // The package list first: no name is asked until it shows the app holds + // objects of its own. + const own = readObjectScope({ packageId }); + if (!own.settled) return { candidate, scope: own }; + if (!own.served.some((o: any) => o?._packageId === packageId)) return { candidate, scope: null }; // The objects the app's own navigation entries require: the sidebar's // capability gate answers from this scope. const names = navigationNames(app?.navigation, (item) => item.requiresObject); From e7a2e8ad45720b4916377c7f0dd081ba4a974416 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 07:41:31 +0000 Subject: [PATCH 7/7] perf(app-shell): the route waits for its package list and its own object, not its lookup targets (objectui#12811) Measured on HotCRM behind a 50 ms round trip: the lookup targets (asked by name once the package list lands) held the splash one more round trip. They decide only whether a lookup cell draws a link, and the requiresObject names only whether an entry stays shown; both are asked beside the route and land after it, as the whole list's did. Claude-Session: https://claude.ai/code/session_01AxUw9eBGehV2FAP7Smwn3N Co-authored-by: Claude --- packages/app-shell/src/console/AppContent.tsx | 4 ++-- .../AppContent.objectScope-12811.test.tsx | 12 ++++++++++ .../app-shell/src/console/appObjectScope.ts | 23 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index eecc6dd02f..24a154b948 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -287,7 +287,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // 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 } = readAppObjectScope( + const { candidate: scopeCandidate, scope: objectScope, ready: objectScopeReady } = readAppObjectScope( readObjectScope, activeApp, appSubPath, @@ -305,7 +305,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = ...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []), ].join(','); const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus); - const routeReadsSettled = bucketsSettled && (!objectScope || objectScope.settled); + 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. diff --git a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx index 37a43b71d1..84cd9718a0 100644 --- a/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx +++ b/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx @@ -208,6 +208,7 @@ describe('one console open asks for the app\'s objects, not the whole registry ( boot('/apps/crm/crm_account'); await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId('cloud-only-entry')).toHaveTextContent('false')); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('sys_user')); expect(objectCalls()).not.toContain('object'); expect(objectCalls()[0]).toBe(`object?package=${CRM}`); @@ -258,6 +259,17 @@ describe('an object outside the package is read by name, behind the skeleton (ob expect(objectCalls()).not.toContain('object'); }); + it('does not hold the route for its lookup targets: they are asked beside it, and the list renders meanwhile', async () => { + server.holdByName = true; + boot('/apps/crm/crm_account'); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('crm_account,crm_contact')); + expect(server.calls).toContain('object/sys_user'); + expect(screen.getByTestId('object-view')).not.toHaveTextContent('sys_user'); + + act(() => server.release()); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('sys_user')); + }); + it('after the first paint, shows the route skeleton inside the chrome, never an empty route, until it lands', async () => { boot('/apps/crm/crm_contact'); await waitFor(() => expect(screen.getByTestId('object-view')).toBeInTheDocument()); diff --git a/packages/app-shell/src/console/appObjectScope.ts b/packages/app-shell/src/console/appObjectScope.ts index 94d1866311..694d63ab2f 100644 --- a/packages/app-shell/src/console/appObjectScope.ts +++ b/packages/app-shell/src/console/appObjectScope.ts @@ -105,6 +105,14 @@ export interface AppObjectScopeRead { candidate: boolean; /** The app's object scope, or `null` when this route reads the whole registry. */ scope: AppObjectScope | null; + /** + * The route can render: the package list and the route's own object have + * answered. The other names (lookup targets, required objects, the + * palette's) are asked beside it, not waited for: until they land, a lookup + * cell draws its value without a link, and a `requiresObject` entry stays + * shown, as it did while the whole list loaded. + */ + ready: boolean; } /** @@ -121,12 +129,13 @@ export function readAppObjectScope( const route = matchObjectScopeRoute(subPath, hostPaths); const candidate = !!route && !!readObjectScope && !hasRecordOverlay(search); const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; - if (!candidate || !packageId || !readObjectScope || !route) return { candidate, scope: null }; + if (!candidate || !packageId || !readObjectScope || !route) return { candidate, scope: null, ready: false }; // The package list first: no name is asked until it shows the app holds // objects of its own. const own = readObjectScope({ packageId }); - if (!own.settled) return { candidate, scope: own }; - if (!own.served.some((o: any) => o?._packageId === packageId)) return { candidate, scope: null }; + if (!own.settled) return { candidate, scope: own, ready: false }; + if (!own.served.some((o: any) => o?._packageId === packageId)) return { candidate, scope: null, ready: false }; + const ready = readObjectScope({ packageId, names: route.objectName ? [route.objectName] : [] }).settled; // The objects the app's own navigation entries require: the sidebar's // capability gate answers from this scope. const names = navigationNames(app?.navigation, (item) => item.requiresObject); @@ -136,9 +145,13 @@ export function readAppObjectScope( } const first = readObjectScope({ packageId, names }); const routeDef = route.objectName ? first.objects.find((o: any) => o?.name === route.objectName) : undefined; - if (routeDef && opensRecordsInPageOverlay(routeDef)) return { candidate, scope: null }; + if (routeDef && opensRecordsInPageOverlay(routeDef)) return { candidate, scope: null, ready: false }; const lookups = routeDef ? lookupTargetNames(routeDef) : []; - return { candidate, scope: lookups.length > 0 ? readObjectScope({ packageId, names: [...names, ...lookups] }) : first }; + return { + candidate, + scope: lookups.length > 0 ? readObjectScope({ packageId, names: [...names, ...lookups] }) : first, + ready, + }; } /**