Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27626-record-open-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

perf(console): opening a record no longer re-reads the list under it, asks its field access once, and the bell polls once a minute

Clause-②: no
Metadata-protocol: none

Measured in the console on app-crm, opening one record from a list and closing it again:

- **The list under the drawer is not read again.** The first record opened replaced the list with the route skeleton (the drawer made the route read the whole object registry instead of the app's object scope, and the route waited for it), so the list mounted again and re-read its rows; every later open and close handed the list a different document for its object, which rebuilt its columns and re-ran the list query. The drawer now reads the whole registry beside the route's scope, without holding the route, and the registry joins the scope once it lands with the scope's own documents kept as they are. The list page also holds `columns` and `searchableFields` by value, as it holds the view-type blocks. The record form overlay (`?form=`) still reads the whole registry.
- **One field-access request per record.** The record's sections, highlights, form and page each asked `GET /data/:object/:id/field-access` at once; `ObjectStackAdapter.getRecordFieldAccess` now shares one request between concurrent asks for the same record, remembers a 404 for that record until a write to its object, and stops asking a server that answers 501. A settled answer is not cached: the next ask after a save reaches the server.
- **The bell polls once a minute.** The inbox and approvals feeds polled every 10 s and 30 s on every console page. With no realtime channel in the platform, they now poll every 60 s in the foreground, still stop in a hidden tab, and stop polling altogether while a host reports an open push channel (a pushed change reads the feed it names). A new inbox message can show up to about a minute later.
38 changes: 36 additions & 2 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { useLauncherApps } from '../hooks/useLauncherApps.js';
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
import { RECORD_DRAWER_PARAM, RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
Expand Down Expand Up @@ -462,8 +462,23 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// 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);
// objectstack#27626 — a record drawer (`?recordId=`) over a scoped route
// reads the whole registry for its related lists, BESIDE the route's scope:
// the route slot never waits on it, and once it has landed it joins the
// scope, whose own documents keep their identity. The list under the drawer
// reads the same object definition before, during and after it, so opening
// or closing a record re-reads none of its rows (the skeleton this used to
// show remounted the list on every first open).
const drawerReadsRegistry = !!objectScope && searchParamsHasRecordDrawer(location.search);
const drawerRegistrySettled = useLazyMetadataSettled(drawerReadsRegistry ? 'object' : '', ensureType, getTypeStatus);
const scopeObjects: any[] | undefined = objectScope?.objects;
const registryObjects: any[] | undefined = drawerReadsRegistry && drawerRegistrySettled ? metadata.objects : undefined;
const scopeWithRegistry = useMemo(
() => (scopeObjects && registryObjects ? joinRegistryToScope(scopeObjects, registryObjects) : undefined),
[scopeObjects, registryObjects],
);
const allObjects: any[] = objectScope
? objectScope.objects
? scopeWithRegistry ?? objectScope.objects
: readsWholeRegistry ? metadata.objects : NO_OBJECTS;
const routeReadsKey = [
...routeReads.filter((type) => type !== 'object' || readsWholeRegistry),
Expand Down Expand Up @@ -1941,6 +1956,25 @@ function searchParamsHasRecordForm(search: string): boolean {
return sp.has(RECORD_FORM_PARAM) || sp.has(RECORD_FORM_OBJECT_PARAM);
}

/**
* objectstack#27626 — whether the record drawer is open in the URL. Over a
* scoped route it reads the whole registry beside the scope.
*/
function searchParamsHasRecordDrawer(search: string): boolean {
return new URLSearchParams(search).has(RECORD_DRAWER_PARAM);
}

/**
* objectstack#27626 — the scope's objects, each the same document, then the
* registry's objects the scope does not hold. The scope itself while the
* registry adds nothing.
*/
function joinRegistryToScope(scope: any[], registry: any[]): any[] {
const held = new Set(scope.map((o: any) => o?.name));
const extra = registry.filter((o: any) => !held.has(o?.name));
return extra.length > 0 ? [...scope, ...extra] : scope;
}

const NO_OBJECTS: any[] = [];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
* and so does the root of an app whose package holds no objects of its own (a
* platform app such as Setup; its object routes read by name, objectui#13046);
* a record overlay (`?recordId=`) reads the whole registry, because a record's
* related lists come from every object that points at it.
* related lists come from every object that points at it (since
* objectstack#27626 the drawer reads it beside the list's scope, and the list
* does not wait for it: `AppContent.recordDrawerKeepsScope-27626.test.tsx`).
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -253,10 +255,9 @@ describe('one console open asks for the app\'s objects, not the whole registry (

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());
await waitFor(() => expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case'));

expect(objectCalls()).toContain('object');
expect(screen.getByTestId('object-view')).toHaveTextContent('crm_case');
});
});

Expand Down
Loading
Loading