From ad4df19131714811bfe0b68cdf268e01dc3582f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:22:02 +0000 Subject: [PATCH 1/2] perf(app-shell): a packaged app's record route reads the objects pointing at the record through its package scope, not the registry A record route whose object has no declared record page, in a packaged app, now reads the app's package scope with the names the bundle gives it (the objects that point at the record's object), as the package-less route already does. A pointing object from another package is read by name and answered from the bundle. The harness's packaged CONTROL that pinned the registry read is flipped. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jStt5LFpRDtMbVaQyh --- ...1-packaged-record-route-related-objects.md | 10 ++ ...nt.packagelessRecordRelated-29552.test.tsx | 122 ++++++++++++++++-- .../app-shell/src/console/appObjectScope.ts | 34 +++-- 3 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 .changeset/29681-packaged-record-route-related-objects.md diff --git a/.changeset/29681-packaged-record-route-related-objects.md b/.changeset/29681-packaged-record-route-related-objects.md new file mode 100644 index 00000000000..191007cc2f9 --- /dev/null +++ b/.changeset/29681-packaged-record-route-related-objects.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +perf(app-shell): a record in a packaged app reads the objects that point at it through the app's package, not the whole object registry + +Clause-②: no +Metadata-protocol: none + +Opening a record in a packaged app, when its object has no declared record page, read the whole object registry so the synthesized page could find the objects that point at the record's object for its related lists: 561 KB of a 1.58 MB record open on the CRM example. When the app's navigation reaches the record's object, the app bundle already names every object that points at it, so the route now reads the app's package list and, by name, the record's lookup targets and any pointing object from another package (answered from the bundle). The related lists are the same, in the same order. The route still reads the registry for an object outside the app's navigation, with no bundle (draft preview, or a host without one), and under a record drawer. diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx index e014938695c..2046b45feb2 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx @@ -34,9 +34,12 @@ * drawn in the order the registry draws them, even when an app's * `requiresObject` names one of the related objects (it lands first); * (d) CONTROLs, the registry as before: an object outside the app's - * navigation, no bundle, a bundle that holds no app, a record drawer - * over the route, and a packaged app's record route (byte-identical: - * the one registry read, nothing by name). + * navigation, no bundle, a bundle that holds no app, and a record drawer + * over the route. + * + * objectstack#29681 — the last describe pins the same route in a packaged app, + * read through its package scope; it replaces the CONTROL that pinned that + * route to the registry. */ import '@testing-library/jest-dom/vitest'; @@ -143,7 +146,7 @@ function serverBundle(app: Doc) { const reach = ['crm_account', 'crm_contact']; const points = (o: Doc) => Object.values((o.fields ?? {}) as Record).some((f) => (f.type === 'lookup' || f.type === 'master_detail') && reach.includes(f.reference)); - const members = OBJECTS.filter((o) => reach.includes(o.name) || points(o)); + const members = server.objects.filter((o) => reach.includes(o.name) || points(o)); const items = new Map }>>(); const put = (type: string, docs: Doc[]) => { const byName = new Map(); @@ -159,7 +162,7 @@ function serverBundle(app: Doc) { } type Peek = { status: 'none' } | { status: 'pending'; settled: Promise } | { status: 'ready'; items: ReturnType }; -const server = { calls: [] as string[], peek: null as Peek | null }; +const server = { calls: [] as string[], peek: null as Peek | null, objects: OBJECTS, pages: [] as Doc[] }; function makeAdapter() { const host: Record = { @@ -174,7 +177,8 @@ function makeAdapter() { meta: { getItems: (type: string, options?: { packageId?: string }) => { server.calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); - const all: Doc[] = type === 'object' ? OBJECTS : type === 'app' ? [OPS_APP, REQUIRES_APP, CRM_APP] : []; + const all: Doc[] = + type === 'object' ? server.objects : type === 'app' ? [OPS_APP, REQUIRES_APP, CRM_APP] : type === 'page' ? server.pages : []; const items = options?.packageId ? all.filter((o) => o._packageId === options.packageId) : all; return Promise.resolve({ type, items: structuredClone(items) }); }, @@ -184,7 +188,7 @@ function makeAdapter() { const bundled = peek?.status === 'ready' ? peek.items.get(type)?.get(name) : undefined; if (bundled) return Promise.resolve(structuredClone(bundled.envelope)); server.calls.push(`${type}/${name}`); - const item = (type === 'object' ? OBJECTS : []).find((o) => o.name === name); + const item = (type === 'object' ? server.objects : []).find((o) => o.name === name); return item ? Promise.resolve({ type, name, item: structuredClone(item) }) : Promise.reject(Object.assign(new Error('not found'), { httpStatus: 404, code: 'RESOURCE_NOT_FOUND' })); @@ -233,11 +237,15 @@ const sorted = (list: readonly string[]) => [...list].sort(); const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); /** The related lists the whole registry draws for `objectName` today. */ const registryRelated = (objectName: string) => - deriveRelatedLists(OBJECTS.find((o) => o.name === objectName) as never, OBJECTS as never[]).map((r) => `${r.childObject}.${r.referenceField}`); + deriveRelatedLists(server.objects.find((o) => o.name === objectName) as never, server.objects as never[]).map( + (r) => `${r.childObject}.${r.referenceField}`, + ); const drawnRelated = () => (screen.getByTestId('record-view').getAttribute('data-related') ?? '').split(',').filter(Boolean); beforeEach(() => { server.calls = []; + server.objects = OBJECTS; + server.pages = []; server.peek = { status: 'ready', items: serverBundle(OPS_APP) }; localStorage.clear(); sessionStorage.clear(); @@ -357,13 +365,105 @@ describe('CONTROLs: the registry as before (objectstack#29552)', () => { await waitFor(() => expect(objectCalls()).toContain('object')); }); - it('a packaged app’s record route is unchanged: the registry, and nothing by name', async () => { +}); + +/** + * objectstack#29681 — the same route in a PACKAGED app reads its names through + * the app's package scope: the package list, then by name each name the list + * does not hold. The CONTROL that pinned this route to the registry + * ("a packaged app's record route is unchanged") is flipped here. + * + * Measured on a production Console (app-crm, `crm_app`, the Initech + * `crm_account` record, no declared record page): the registry was 561,247 B + * of the route's 1,584,710 B, with the `/meta/view` list (7,014 B) read beside + * it. + */ +describe('a packaged app’s record route with no declared record page reads its package scope, not the registry (objectstack#29681)', () => { + /** + * An object outside the app's package that points at the account, listed + * between the package's own objects: saved in the environment, as an object + * created in Studio is. The bundle holds it; the package list does not. + */ + const VISIT: Doc = { name: 'ops_visit', _packageId: 'sys_metadata', fields: { account: { type: 'lookup', reference: 'crm_account' } } }; + const WITH_VISIT: Doc[] = [...OBJECTS.slice(0, 2), VISIT, ...OBJECTS.slice(2)]; + const ACCOUNT_PAGE: Doc = { + name: 'account_detail', + type: 'record', + object: 'crm_account', + kind: 'full', + regions: [{ name: 'main', components: [{ type: 'record:related_list', properties: { objectName: 'crm_contact' } }] }], + }; + + beforeEach(() => { + server.objects = WITH_VISIT; server.peek = { status: 'ready', items: serverBundle(CRM_APP) }; + }); + + it('reads no unscoped object list: the package list, and by name only the lookup target outside the bundle', async () => { boot('/apps/crm/crm_account/record/r1'); - await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('crm_activity')); + await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('ops_visit')); + await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('sys_user')); await settle(); + expect(objectCalls()).not.toContain('object'); + // The object of another package is answered from the bundle, asked of no route. + expect(objectCalls()).toEqual(['object?package=app.crm', 'object/sys_user']); + }); + + it('draws the registry’s related lists in the registry’s order, the object of another package in its place', async () => { + boot('/apps/crm/crm_account/record/r1'); + await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('ops_visit')); + await settle(); + + expect(registryRelated('crm_account')).toEqual([ + 'crm_opportunity.account', + 'crm_activity.what', + 'ops_visit.account', + 'crm_contact.account', + 'crm_lead.account', + 'crm_opportunity.partner', + ]); + expect(drawnRelated()).toEqual(registryRelated('crm_account')); + }); + + it('an object pointed at only from another package draws that list', async () => { + boot('/apps/crm/crm_contact/record/c1'); + await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('crm_case')); + await settle(); + + expect(objectCalls()).not.toContain('object'); + expect(registryRelated('crm_contact')).toEqual(['crm_case.contact']); + expect(drawnRelated()).toEqual(registryRelated('crm_contact')); + }); + + it('CONTROL: a declared record page reads the objects it binds, as before', async () => { + server.pages = [ACCOUNT_PAGE]; + boot('/apps/crm/crm_account/record/r1'); + await waitFor(() => expect(screen.getByTestId('record-view')).toHaveTextContent('crm_contact')); + await settle(); + + expect(objectCalls()).toEqual(['object?package=app.crm', 'object/sys_user']); + expect(screen.getByTestId('record-view').textContent).not.toContain('ops_visit'); + }); + + it('CONTROL: an object outside the app’s navigation reads the registry', async () => { + boot('/apps/crm/crm_opportunity/record/o1'); + await waitFor(() => expect(objectCalls()).toContain('object')); + await settle(); expect(objectCalls()).toEqual(['object']); - expect(sorted(drawnRelated())).toEqual(sorted(registryRelated('crm_account'))); + }); + + it('CONTROL: no bundle reads the registry', async () => { + server.peek = null; + boot('/apps/crm/crm_account/record/r1'); + await waitFor(() => expect(objectCalls()).toContain('object')); + await settle(); + expect(objectCalls()).toEqual(['object']); + }); + + it('CONTROL: a record drawer over the route reads the registry beside it (objectstack#27626)', async () => { + boot('/apps/crm/crm_account/record/r1?recordId=r2'); + await waitFor(() => expect(objectCalls()).toContain('object')); + expect(objectCalls()).toContain('object?package=app.crm'); }); }); diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index aaeb5fa7d06..0daa38fdba2 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -40,12 +40,16 @@ * (`usePageAssignment`'s choice). A record route with no declared record page, * or a slotted one, reads the whole registry: its synthesized page derives * the related lists from every object that points at the record's object, in - * any package. objectstack#29552 — except in an app with no package, for an - * object its navigation reaches: the app bundle holds every object that points - * at it (`BundledReads.objectsReferencing`), so the route reads those by name, - * with the object's own names, instead (measured on a production Console: the - * registry was 553 KB of a console-created app's 888 KB record route). Until - * the bundle has answered it waits; an object outside the navigation, no + * any package. objectstack#29552 — except for an object the app's navigation + * reaches: the app bundle holds every object that points at it + * (`BundledReads.objectsReferencing`), so the route reads those by name, with + * the object's own names, instead (measured on a production Console: the + * registry was 553 KB of a console-created app's 888 KB record route). + * objectstack#29681 — a packaged app reads them through its package scope, as + * its object routes read their names: the package list, then by name each + * name it does not hold, an object of another package pointing at the record's + * among them (the registry was 561 KB of `crm_app`'s 1.58 MB record route). + * Until the bundle has answered it waits; an object outside the navigation, no * bundle, or draft preview reads the registry as before. * * Everywhere else the route reads the whole registry, as before. For the @@ -508,17 +512,19 @@ export function readAppObjectScope( if (!pagesRead?.settled) return { candidate, scope: PENDING_OBJECT_SCOPE, ready: false, ...NOT_READ }; const page = declaredRecordPage(pagesRead.pages, route.objectName, route.pageName, typeof app.name === 'string' ? app.name : undefined); if (!page) { - // objectstack#29552 — the synthesized page of an app with no package: - // its related lists are derived from every object that points at the - // record's object, which the app bundle holds for an object the app's - // navigation reaches. Those are read by name with the route's own names - // (a bundled object is answered from the bundle, asked of no route). - // A packaged app, and a bundle that cannot say, read the registry. - if (packageId) return { candidate, scope: null, ready: false, ...NOT_READ }; + // objectstack#29552 — the synthesized page: its related lists are + // derived from every object that points at the record's object, which + // the app bundle holds for an object the app's navigation reaches. Those + // are read by name with the route's own names (a bundled object is + // answered from the bundle, asked of no route). objectstack#29681 — a + // packaged app reads them through its package scope: the package list, + // then by name each one it does not hold (an object of another package + // that points at the record's). A bundle that cannot say reads the + // registry. const referencing = readObjectsReferencing?.(route.objectName); if (referencing?.state === 'pending') return { candidate, scope: PENDING_OBJECT_SCOPE, ready: false, ...NOT_READ }; if (referencing?.state !== 'hit') return { candidate, scope: null, ready: false, ...NOT_READ }; - return { candidate, ...readObjectRouteNames(readObjectScope, app, route.objectName, search, undefined, referencing.value) }; + return { candidate, ...readObjectRouteNames(readObjectScope, app, route.objectName, search, packageId, referencing.value) }; } const extra = [route.objectName, ...appRequiredObjectNames(app)]; if (new URLSearchParams(search).has(COMMAND_PALETTE_PARAM)) { From daaec33a8a206d26dbae17eb846edaa27db3a8be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:56:53 +0000 Subject: [PATCH 2/2] test(app-shell): the packaged record drawer pin asks only for the registry beside the route Live, the drawer over a packaged record route reads the registry alone (the package scope is cut from it), the same requests as before; the harness's extra package-list assertion pinned its own call order. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jStt5LFpRDtMbVaQyh --- .../__tests__/AppContent.packagelessRecordRelated-29552.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx index 2046b45feb2..88e1a0b156b 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.packagelessRecordRelated-29552.test.tsx @@ -464,6 +464,5 @@ describe('a packaged app’s record route with no declared record page reads its it('CONTROL: a record drawer over the route reads the registry beside it (objectstack#27626)', async () => { boot('/apps/crm/crm_account/record/r1?recordId=r2'); await waitFor(() => expect(objectCalls()).toContain('object')); - expect(objectCalls()).toContain('object?package=app.crm'); }); });