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..b76290f947 --- /dev/null +++ b/.changeset/12811-scoped-object-metadata-app-shell.md @@ -0,0 +1,32 @@ +--- +'@object-ui/app-shell': patch +--- + +perf(app-shell): a console open reads the current app's objects, not the whole object registry (objectui#12811) + +Every console open used to request `GET /api/v1/meta/object` for every registered object (97 objects +and 745 KB on the measured backend), whichever app it opened. Opening an app's root or an object's +list now requests `GET /api/v1/meta/object?package=ID` with the package id the app's served item +carries. An object the route needs from outside that package is requested by name: the route's object, +the objects its lookup fields point at, the objects the app's navigation entries name in +`requiresObject`, and, while the command palette is open, the navigation's objects. The header's +view-tab labels and the bell's activity feed read the same scope, and `useObjectPresence` asks an +object by name inside such an app, until something loads the whole registry. Until those answers land, the route shows +its skeleton, never an empty list or a "not found". + +What still reads the whole registry, as before: + +- An app item that carries no package id, an app whose package holds no objects of its own (a platform + app such as Setup, whose entries all open other packages' objects), and every route outside an app + (the home page). +- A record overlay (`?recordId=`, `?form=`) and every record route: a record's related lists come from + every object that points at its object, in any package. +- An object list whose views open records in an overlay the URL does not carry (`modal`, `split`, + `popover`). +- Dashboards, reports, pages, search, Studio, the metadata admin and the app designer. +- An action that opens an object's form in a modal, when it opens: the form's subforms come from every + object. The console no longer loads the whole registry for this at startup. + +`useMetadata().objects` keeps its meaning: it is the whole registry wherever it is read. The package +read is refreshed and dropped with the `object` type (`refresh`, `invalidate`, an organization switch) +and is never served from a clock. diff --git a/.changeset/12811-scoped-object-metadata-react.md b/.changeset/12811-scoped-object-metadata-react.md new file mode 100644 index 0000000000..3d98c99de5 --- /dev/null +++ b/.changeset/12811-scoped-object-metadata-react.md @@ -0,0 +1,16 @@ +--- +'@object-ui/react': minor +--- + +feat(react): `MetadataContextValue.readObjectScope`, the object definitions one app reads (objectui#12811) + +A new optional member of the metadata context. `readObjectScope({ packageId, names })` answers the +objects of one package, read with `GET /api/v1/meta/object?package=ID`, plus each of `names` that the +package does not hold, read by name. Each definition is shaped exactly as the same object in +`objects`. The answer carries `objects`, `served` (the same definitions as the server served them, +before views are merged, as `getItemsByType('object')` holds the whole list), `missing` (the names the +server answered without a definition) and `settled` (the package list and every name have answered). With no `packageId` it is +the whole registry, as `objects` is. + +`objects` is unchanged: it is the whole registry. A context value without the member (a hand-rolled +one in a test, an older host) has nothing narrower to offer, and its readers read `objects`. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index cf0123d4e9..24a154b948 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -55,6 +55,7 @@ import { appServesNoNavigation } from './appServesNoNavigation.js'; import { useNavMemberGates, navEntriesShownToMember } from '../hooks/useNavMemberGates.js'; import { resolveSetupAppPath } from './ConsoleShell.js'; import { collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js'; +import { readAppObjectScope } from './appObjectScope.js'; import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; // Route-based code splitting — lazy-load less-frequently-used routes @@ -196,7 +197,10 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const navigate = useNavigate(); const location = useLocation(); const { appName } = useParams(); - const { apps, objects: allObjects, loading: metadataLoading, ensureType, getTypeStatus, error: metadataError, refresh: refreshMetadata } = useMetadata(); + // ⛔ `objects` is not destructured here: reading it loads the whole registry + // (objectui#12811). It is read below only on a route that needs all of it. + const metadata = useMetadata(); + const { apps, loading: metadataLoading, ensureType, getTypeStatus, error: metadataError, refresh: refreshMetadata, readObjectScope } = metadata; // objectui#7373 — where this file's two "you cannot be here" exits land. Both // sit BELOW the readiness gate further down (`metadataLoading` &c), so the // list they resolve against has settled; an app list that failed to load is @@ -207,29 +211,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const { t } = useObjectTranslation(); const { objectLabel, objectPluralLabel } = useObjectLabel(); - // objectui#12809 — the lazy metadata buckets the CURRENT route reads at - // render (`appRouteMetadataReads.ts` declares them per route). This used to - // be a preload of all four (`object`, `dashboard`, `report`, `page`) that held - // the splash on every route; now the splash holds only for the current - // route's, and a later navigation onto a route whose buckets have not landed - // shows a skeleton in the route slot (below) instead of an empty list or a - // "not found". The record-form overlay (`?form=`) edits a record of an object, - // so it reads `object` on any route. - const hostRoutePaths = useMemo( - () => [...collectRoutePaths(extraRoutes), ...collectRoutePaths(extraRoutesNoApp)], - [extraRoutes, extraRoutesNoApp], - ); - const appSubPath = location.pathname.split('/').filter(Boolean).slice(2).join('/'); - const routeReads = metadataReadsForAppRoute(appSubPath, hostRoutePaths); - const routeReadsKey = [ - ...routeReads, - ...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []), - ].join(','); - const routeReadsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus); - // Set once the first frame past the splash has rendered. After it, a route - // waiting on its buckets draws its skeleton inside the console chrome rather - // than taking the whole console back to the splash. - const firstPaintDone = useRef(false); // Hidden apps (`App.hidden`) are excluded from app-listing surfaces // (sidebar switcher, home grid, app switcher). The active app for the @@ -287,6 +268,49 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // the recent-items tracker below matches it against the URL (objectui#11818). const appSegment = appName ?? appRouteSegment(activeApp); + // objectui#12809 — the lazy metadata buckets the CURRENT route reads at + // render (`appRouteMetadataReads.ts` declares them per route). This used to + // be a preload of all four (`object`, `dashboard`, `report`, `page`) that held + // the splash on every route; now the splash holds only for the current + // route's, and a later navigation onto a route whose buckets have not landed + // shows a skeleton in the route slot (below) instead of an empty list or a + // "not found". The record-form overlay (`?form=`) edits a record of an object, + // so it reads `object` on any route. + const hostRoutePaths = useMemo( + () => [...collectRoutePaths(extraRoutes), ...collectRoutePaths(extraRoutesNoApp)], + [extraRoutes, extraRoutesNoApp], + ); + const appSubPath = location.pathname.split('/').filter(Boolean).slice(2).join('/'); + const routeReads = metadataReadsForAppRoute(appSubPath, hostRoutePaths); + + // objectui#12811 — which objects this route and the chrome around it read: + // the app's object scope on the routes `appObjectScope.ts` names, inside an + // app whose served item carries its package; the whole registry everywhere + // else, as before (that module says why). + const { candidate: scopeCandidate, scope: objectScope, ready: objectScopeReady } = readAppObjectScope( + readObjectScope, + activeApp, + appSubPath, + location.search, + hostRoutePaths, + ); + // Until the app list has loaded, a scope candidate does not know its package + // yet, and reads nothing (the splash is up). + const readsWholeRegistry = !scopeCandidate || (!metadataLoading && !objectScope); + const allObjects: any[] = objectScope + ? objectScope.objects + : readsWholeRegistry ? metadata.objects : NO_OBJECTS; + const routeReadsKey = [ + ...routeReads.filter((type) => type !== 'object' || readsWholeRegistry), + ...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []), + ].join(','); + const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus); + const routeReadsSettled = bucketsSettled && (!objectScope || objectScopeReady); + // Set once the first frame past the splash has rendered. After it, a route + // waiting on its buckets draws its skeleton inside the console chrome rather + // than taking the whole console back to the splash. + const firstPaintDone = useRef(false); + // A normal app was requested but isn't present in the loaded metadata — the // post-publish readiness lag, or a genuinely-missing app. Applies in BOTH // preview and published mode (preview already guarded this; published used to @@ -1167,7 +1191,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 ? : ( ({ + ...(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' }, + // 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 { + 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')); + await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('sys_user')); + + expect(objectCalls()).not.toContain('object'); + expect(objectCalls()[0]).toBe(`object?package=${CRM}`); + // 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'); + 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: 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()); + + 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('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()); + + 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__/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(); diff --git a/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx b/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx new file mode 100644 index 0000000000..ebfb88b129 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12811 — the console root starts the app's object scope. + * + * Measured on HotCRM behind a 50 ms round trip: the whole object list used to + * start at the console root (`useActionModal` read `objects`), beside the + * session's permission read. `AppContent` mounts only after that read, so a + * scope it alone asked for started one permission round trip later. + * `useAppObjectScopePrefetch` (mounted by `ConsoleShell`'s root action runtime) + * asks the scope as soon as the app list names the app: the package list, + * then by name the route object's lookup targets outside the package. + * + * CONTROLs: a route outside an app, a record overlay and an app item with no + * package id ask nothing here (`AppContent` reads the whole registry there). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { useAppObjectScopePrefetch } from '../appObjectScope'; + +const CRM = 'app.crm'; +const OBJECTS = [ + { name: 'crm_account', _packageId: CRM, fields: { owner: { type: 'lookup', reference: 'sys_user' } } }, + { name: 'sys_user', _packageId: 'plugin.auth', fields: {} }, +]; + +function makeAdapter(calls: string[], apps: unknown[]) { + 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 : 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/__tests__/openPathObjectReaders-12811.test.ts b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts new file mode 100644 index 0000000000..c51d7004dd --- /dev/null +++ b/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -0,0 +1,191 @@ +// 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 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; + * - `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, 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. + */ + +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/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", + }, + '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 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'; + +/** 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; + if (ts.isIdentifier(key) && key.text === 'getItemsByType' && ts.isIdentifier(el.name)) bindings.add(`fn:${el.name.text}`); + } + } + } + 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; + } + // `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); + 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/console/appObjectScope.ts b/packages/app-shell/src/console/appObjectScope.ts new file mode 100644 index 0000000000..694d63ab2f --- /dev/null +++ b/packages/app-shell/src/console/appObjectScope.ts @@ -0,0 +1,170 @@ +/** + * 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, 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 + * 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 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; +} + +/** + * 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, 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, 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); + 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, ready: false }; + const lookups = routeDef ? lookupTargetNames(routeDef) : []; + return { + candidate, + scope: lookups.length > 0 ? readObjectScope({ packageId, names: [...names, ...lookups] }) : first, + ready, + }; +} + +/** + * 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); +} 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..999ac78078 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/scopedRegistryReaders-12811.test.tsx @@ -0,0 +1,113 @@ +// 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']; +} + +type Reading = { + served: ReturnType; + presence: ReturnType; + ctx: ReturnType; +}; + +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( + + + + {extra} + + , + ); + return () => latest as unknown as Reading; +} + +const objectCalls = (calls: string[]) => calls.filter((c) => c === 'object' || c.startsWith('object?') || c.startsWith('object/')); + +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[] = []; + 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(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[] = []; + 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[] = []; + const read = mount({ name: 'crm', _packageId: CRM }, calls); + await waitFor(() => expect(read().presence.settled).toBe(true)); + act(() => { + void read().ctx.objects; + }); + await waitFor(() => expect(read().ctx.getTypeStatus!('object')).toBe('ready')); + expect(read().served.objects).toHaveLength(OBJECTS.length); + expect(read().presence.presence).toBe('present'); + }); +}); 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,41 @@ 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, 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.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; 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); + } + return registeredObjectNames.has(name) || !missingObjectNames.has(name); } return true; }, - [registeredObjectNames], + [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 370553ac34..7cfe975614 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,89 @@ 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; + const served = readType('object'); + return { + objects: composeObjects(served, readType('view')) as any[], + served, + 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[], + served: entry.items, + 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 +1317,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..47a1a7e391 100644 --- a/packages/react/src/context/AppShellContext.tsx +++ b/packages/react/src/context/AppShellContext.tsx @@ -61,6 +61,27 @@ 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`; `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 + * hand-rolled context without it has nothing narrower to offer, and its + * readers read `objects`. + */ + readObjectScope?: (scope: { packageId?: string; names?: readonly string[] }) => { + objects: any[]; + served: any[]; + missing: readonly string[]; + settled: boolean; + }; } export const MetadataCtx = createContext(null);