diff --git a/.changeset/28645-app-page-reads-from-bundle.md b/.changeset/28645-app-page-reads-from-bundle.md new file mode 100644 index 00000000000..597f19b1e11 --- /dev/null +++ b/.changeset/28645-app-page-reads-from-bundle.md @@ -0,0 +1,20 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/data-objectstack': patch +--- + +perf(console): an app page reads its views, its page and its nav dashboards from the app bundle, and reads the `/meta/view`, `/meta/page` and `/meta/dashboard` lists only for what the bundle does not hold + +Clause-②: yes (widening) +Metadata-protocol: none + +Since the app metadata bundle landed, entering an app downloads the app, its objects and their views, pages and dashboards in one request. The console still read the per-type lists beside it. Now: + +- **`view` is no longer read at mount.** An object the bundle holds is composed with the bundle's views. A route's object scope reads the `/meta/view` list only when a name it asks is outside the bundle, and then only for the objects the bundle does not hold; such a scope waits for the list before it answers. The whole object registry still reads the list beside it. +- **The console enters the app's bundle once, at its root**, as soon as the app list names the app, and `AppContent` reuses that entry instead of asking the bundle again. Leaving the app ends the entry, so coming back revalidates it. +- **Whether an object exists** (the presence check of the bell and activity surfaces) is asked without views, so it never reads the `/meta/view` list. +- **The page a page route opens** (`/apps/APP/page/NAME`), the phone bar's page or dashboard title, a dashboard nav entry's label and a recent page's label are read from the bundle first. A name the bundle does not hold, or a same-named item another package owns, is read from the list as before, prefer-local resolution included. The page route no longer waits for the page list to load. +- **The app bar reads views only on an object's view route**, the one title that labels a view. +- **New in `@object-ui/data-objectstack`:** `ObjectStackAdapter.peekAppBundle()` reads the active app bundle without waiting (`none` before an app is entered, `pending` while its first answer is on the wire, `ready` with its items), and the types `AppBundlePeek`, `AppBundleItems` and `AppBundleEntry` are exported. A stale bundle is revalidated with `If-None-Match` and keeps answering from what it holds until the revalidation lands. + +Navigation sync still reads the page and dashboard lists after the first paint; that read is not changed here. diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 91d2e974444..6cb2274d3e1 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -20,6 +20,7 @@ import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordS import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { useBundledReads } from '../providers/bundledReads.js'; import { useAdapter } from '../providers/AdapterProvider.js'; import { usePreviewDrafts } from '../preview/PreviewModeContext.js'; import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js'; @@ -67,7 +68,7 @@ import { } from '../hooks/useNavMemberGates.js'; import { resolveSetupAppPath } from './ConsoleShell.js'; import { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js'; -import { readAppObjectScope } from './appObjectScope.js'; +import { enterAppBundle, readAppObjectScope } from './appObjectScope.js'; import { preferLocal } from '../utils/preferLocal.js'; import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; @@ -318,6 +319,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // (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; + // objectstack#28645 — what the app bundle answers in render (null outside a + // `MetadataProvider`): the page a page route opens is read there first. + const bundledReads = useBundledReads(); // 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 @@ -395,10 +399,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // Only the app the URL names: a pseudo-route's fallback app is not entered. // A host adapter without the bundle keeps every by-name read. const bundleAppName: string | undefined = typeof matchedApp?.name === 'string' ? matchedApp.name : undefined; + // objectstack#28645 — the console root entered it first (`enterAppBundle`). useEffect(() => { - const load = dataSource?.loadAppBundle; - if (!bundleAppName || typeof load !== 'function') return; - void load.call(dataSource, bundleAppName); + enterAppBundle(dataSource, bundleAppName); }, [dataSource, bundleAppName]); // objectstack-ai/objectstack#26183 — a URL that reached the app through its @@ -456,6 +459,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = location.search, hostRoutePaths, (pageName) => { + // objectstack#28645 — the bundle's page when it holds this package's; + // the `/meta/page` list, prefer-local, for a page it does not hold. + const bundled = bundledReads?.item('page', pageName, (activeApp as any)?._packageId); + if (bundled?.state === 'hit') return { page: bundled.value, settled: true }; + if (bundled?.state === 'pending') return { page: undefined, settled: false }; const status = getTypeStatus?.('page'); return { page: preferLocal(metadata.pages as any[], pageName, (activeApp as any)?._packageId), @@ -493,8 +501,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const allObjects: any[] = objectScope ? scopeWithRegistry ?? objectScope.objects : readsWholeRegistry ? metadata.objects : NO_OBJECTS; + // objectstack#28645 — a page route reads its page bundle-first (`PageView`, + // the page scope above), so it does not wait for the `/meta/page` list: a + // page outside the bundle is read from the list by those readers, which + // wait for it themselves. + const pageRouteReadsBundle = !!bundledReads && /^page\/[^/]+$/.test(appSubPath); const routeReadsKey = [ - ...routeReads.filter((type) => type !== 'object' || readsWholeRegistry), + ...routeReads.filter( + (type) => (type !== 'object' || readsWholeRegistry) && !(type === 'page' && pageRouteReadsBundle), + ), ...(searchParamsHasRecordForm(location.search) && !routeReads.includes('object') ? ['object'] : []), ].join(','); const bucketsSettled = useLazyMetadataSettled(routeReadsKey, ensureType, getTypeStatus); diff --git a/ui/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx b/ui/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx index 087e3f0ccea..0d4d752ed35 100644 --- a/ui/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/ConnectedShell.sessionGate.test.tsx @@ -14,10 +14,13 @@ * - auth pending → zero metadata requests; * - auth resolved → the eager requests, once each. * - * objectui#12811: the eager requests are `app` and `view`. The third, + * objectui#12811: the eager requests were `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. + * objectstack#28645: `view` is no longer eager either — it is read beside the + * whole registry, or for objects outside the app bundle — so the one eager + * request is `app`. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -110,7 +113,7 @@ describe('ConnectedShell session gate (objectui#4042)', () => { await new Promise((resolve) => setTimeout(resolve, 100)); expect(metaCalls.filter((c) => c === 'object')).toHaveLength(0); - expect(metaCalls.filter((c) => c === 'view')).toHaveLength(1); + expect(metaCalls.filter((c) => c === 'view')).toHaveLength(0); expect(metaCalls.filter((c) => c === 'app')).toHaveLength(1); expect(getByTestId('route-content')).toBeTruthy(); }); diff --git a/ui/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx b/ui/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx index 45d0dc924f0..9d205a9b25c 100644 --- a/ui/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx @@ -20,8 +20,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { AdapterCtx } from '@object-ui/react'; import { MetadataProvider } from '../../providers/MetadataProvider'; -import { useAppObjectScopePrefetch } from '../appObjectScope'; +import { enterAppBundle, useAppObjectScopePrefetch } from '../appObjectScope'; const CRM = 'app.crm'; const OBJECTS = [ @@ -96,12 +97,45 @@ describe('the console root starts the app object scope (objectui#12811)', () => expect(objectCalls(calls)).toEqual([`object?package=${CRM}`, 'object/sys_user']); }); + it('objectstack#28645 — enters the app\'s metadata bundle as it starts the scope, once the app list names the app', async () => { + sessionStorage.clear(); + const calls: string[] = []; + const entered: string[] = []; + const adapter = Object.assign(makeAdapter(calls, APPS) as object, { + loadAppBundle: (app: string) => { entered.push(app); return Promise.resolve(); }, + }) as Parameters[0]['adapter']; + render( + + + + + + + , + ); + await waitFor(() => expect(entered).toEqual(['crm'])); + expect(calls).toContain(`object?package=${CRM}`); + // `AppContent` reuses the root's entry rather than asking the bundle again; + // another app, or the app after the root has left it, is entered. + enterAppBundle(adapter, 'crm'); + // The console hands `AppContent` a wrapper of the same adapter. + const wrapper = { loadAppBundle: (app: string) => (adapter as any).loadAppBundle(app) }; + enterAppBundle(wrapper, 'crm'); + expect(entered).toEqual(['crm']); + enterAppBundle(adapter, 'hr'); + expect(entered).toEqual(['crm', 'hr']); + cleanup(); + enterAppBundle(adapter, 'crm'); + expect(entered).toEqual(['crm', 'hr', 'crm']); + }); + it('CONTROL: outside an app, on a record form 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?form=new', APPS); const noPackage = mount('/apps/crm/crm_account', [{ name: 'crm', navigation: [] }]); - await waitFor(() => expect(overlay).toContain('view')); - await waitFor(() => expect(noPackage).toContain('view')); + // objectstack#28645 — the provider's one eager read is the app list. + await waitFor(() => expect(overlay).toContain('app')); + await waitFor(() => expect(noPackage).toContain('app')); await new Promise((resolve) => setTimeout(resolve, 50)); expect(objectCalls(outside)).toEqual([]); expect(objectCalls(overlay)).toEqual([]); diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index dfbdcdf4ad7..1a75ed3b061 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -58,8 +58,9 @@ * permission read rather than after it, as the whole list used to. */ +import { useEffect } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; -import { isRecordPageOf, useMetadata, type MetadataContextValue } from '@object-ui/react'; +import { isRecordPageOf, useAdapter, useMetadata, type MetadataContextValue } from '@object-ui/react'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import { APP_ROUTE_METADATA_READS } from './appRouteMetadataReads.js'; import { matchAppBySegment } from '../utils/appRoute.js'; @@ -437,17 +438,58 @@ export function readAppObjectScope( }; } +/** An adapter that loads app metadata bundles (`ObjectStackAdapter.loadAppBundle`). */ +type BundleHost = { loadAppBundle?: (appName: string) => unknown }; + +/** + * objectstack#28645 — the app the console root entered, while it stays + * entered (one at a time: the root enters the app its route names). Kept by name, not by adapter: below the root, `AppContent` reads a + * wrapper of the same adapter (the console's approval record reads), so the + * adapter's identity does not say whether the root entered it. + */ +const rootEnteredApps = new Set(); + +/** + * Enter `appName`'s metadata bundle (objectstack#27627), unless the console + * root already entered it ({@link useAppObjectScopePrefetch}): `AppContent` + * mounts below the session's permission read, and entering again there would + * ask the bundle a second time. A host adapter without bundles is left alone. + */ +export function enterAppBundle(dataSource: unknown, appName: string | undefined): void { + const host = dataSource as BundleHost | null | undefined; + const load = host?.loadAppBundle; + if (!host || !appName || typeof load !== 'function') return; + if (rootEnteredApps.has(appName)) return; + void load.call(host, appName); +} + /** * 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. + * below the session's permission read). It renders nothing; `AppContent` reads + * the same scope entry when it renders. + * + * objectstack#28645 — it enters the app's metadata bundle at the same moment, + * once, and `AppContent` reuses that entry ({@link enterAppBundle}), so the + * scope composes its views from the bundle instead of reading the + * `/meta/view` list before the app was entered. Leaving the app ends the + * entry, so coming back revalidates the bundle as entering it always has. */ export function useAppObjectScopePrefetch(): void { const { apps, readObjectScope } = useMetadata(); + const dataSource = useAdapter() as BundleHost | null; 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); + const app = segments[0] === 'apps' && segments[1] ? matchAppBySegment(apps, segments[1]) : undefined; + const bundleAppName = typeof app?.name === 'string' && app.name ? app.name : undefined; + useEffect(() => { + const load = dataSource?.loadAppBundle; + if (!dataSource || !bundleAppName || typeof load !== 'function') return; + void load.call(dataSource, bundleAppName); + rootEnteredApps.add(bundleAppName); + return () => { + rootEnteredApps.delete(bundleAppName); + }; + }, [dataSource, bundleAppName]); + if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search); } diff --git a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index 3ab84540819..761158e819f 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -70,6 +70,7 @@ import { resolveViewId } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { bundledFirst, useBundledReads } from '../providers/bundledReads.js'; /** * A metadata label (plain string or inline locale map) as display text, or @@ -139,6 +140,7 @@ function listViewsOf(def: LabelledRecord | undefined): Record metadata.dashboards); const text = metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language); return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' })); @@ -170,6 +174,6 @@ export function useNavTargetLabel(): NavTargetLabelResolver { return undefined; } }, - [metadata, objectLabel, viewLabel, dashboardLabel, language, t, drafts], + [metadata, bundledReads, objectLabel, viewLabel, dashboardLabel, language, t, drafts], ); } diff --git a/ui/packages/app-shell/src/hooks/useObjectPresence.ts b/ui/packages/app-shell/src/hooks/useObjectPresence.ts index 55f29b78ee2..62c7a746183 100644 --- a/ui/packages/app-shell/src/hooks/useObjectPresence.ts +++ b/ui/packages/app-shell/src/hooks/useObjectPresence.ts @@ -107,8 +107,14 @@ export function useObjectPresence(name: string): ObjectPresenceReading { const { app } = useExpressionContext(); const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; const inApp = typeof app?.name === 'string' && app.name !== ''; + // + // objectstack#28645 — it asks with `views: false` (the provider's hand-off, + // `MetadataProvider`): whether an object is there reads no views, so this + // question never reads the `/meta/view` list. if ((packageId || inApp) && readObjectScope && getTypeStatus?.('object') === 'idle') { - const scope = readObjectScope(packageId ? { packageId, names: [name] } : { names: [name] }); + type ReadScope = NonNullable; + const readNames = readObjectScope as (scope: Parameters[0] & { views: false }) => ReturnType; + const scope = readNames(packageId ? { packageId, names: [name], views: false } : { names: [name], views: false }); const presence: ObjectPresence = !scope.settled ? 'unknown' : scope.objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === name) diff --git a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts index fe4fc85f838..46fde56ea21 100644 --- a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -61,6 +61,7 @@ import { useMemo } from 'react'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { RecentItem } from '../context/RecentItemsProvider.js'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { bundledFirst, useBundledReads } from '../providers/bundledReads.js'; import { findByName, metadataText, useNavTargetLabel, type LabelledRecord } from './useNavTargetLabel.js'; /** Answers the display text of a recent entry, in the current language. */ @@ -80,6 +81,7 @@ export function useRecentItemLabel( const { packages } = sources; const targetLabel = useNavTargetLabel(); const metadata = useMetadata(); + const bundledReads = useBundledReads(); const { objectPluralLabel, pageLabel, reportLabel } = useObjectLabel(); const { language } = useObjectTranslation(); @@ -102,7 +104,7 @@ export function useRecentItemLabel( case 'dashboard': return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name; case 'page': { - const text = metadataText(findByName(metadata.pages, item.name)?.label, language); + const text = metadataText(bundledFirst(bundledReads, 'page', item.name, () => metadata.pages)?.label, language); return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name; } case 'report': { @@ -115,6 +117,6 @@ export function useRecentItemLabel( return item.label; } }, - [targetLabel, metadata, objectPluralLabel, pageLabel, reportLabel, language, packages], + [targetLabel, metadata, bundledReads, objectPluralLabel, pageLabel, reportLabel, language, packages], ); } diff --git a/ui/packages/app-shell/src/hooks/useServedViewItems.ts b/ui/packages/app-shell/src/hooks/useServedViewItems.ts index 94e777911f0..6824922fafa 100644 --- a/ui/packages/app-shell/src/hooks/useServedViewItems.ts +++ b/ui/packages/app-shell/src/hooks/useServedViewItems.ts @@ -106,14 +106,22 @@ function payloadOf(items: unknown): readonly unknown[] { return Array.isArray(items) && items.length > 0 ? items : NONE_SERVED; } +/** What a caller that reads no views holds: one identity, both reads empty. */ +const NOTHING_READ: ServedViewReads = Object.freeze({ views: NONE_SERVED, objects: NONE_SERVED }); + /** * The documents the `/meta/view` and `/meta/object` reads served, as the * metadata cache holds them. * * Keyed on the cache's own arrays, so it keeps one identity until either read * is refetched, and a consumer may key a memo on it (AGENTS.md #10). + * + * `read: false` reads nothing and answers both empty (objectstack#28645): a + * caller that labels views only on some routes (the app bar's phone title, on + * an object's view route) passes it everywhere else, so those routes start no + * view or object read for it. */ -export function useServedViewItems(): ServedViewReads { +export function useServedViewItems(read = true): ServedViewReads { const metadata = useMetadata(); // objectui#12811 — inside an app whose served item names its package, and // while nothing has asked for the whole registry, the served object @@ -132,18 +140,34 @@ export function useServedViewItems(): ServedViewReads { // object route anyway (`appObjectScope.ts`), so the condition is false // there and the label sites keep the whole list. const { app } = useExpressionContext(); + if (!read) return NOTHING_READ; const packageId = typeof app?._packageId === 'string' && app._packageId ? app._packageId : undefined; const scoped = !!metadata.readObjectScope && metadata.getTypeStatus?.('object') === 'idle'; + // + // objectstack#28645 — a scope's view documents are the ones it composed its + // objects with: the app bundle's when it holds every object in the scope, + // else the `/meta/view` list (`views`, beside the scope's published shape). let objects: unknown; + let views: unknown; if (scoped && packageId) { - objects = metadata.readObjectScope!({ packageId }).served; + const scope = metadata.readObjectScope!({ packageId }) as { served: unknown; views?: unknown }; + objects = scope.served; + views = scope.views; } else if (scoped && isPackagelessApp(app)) { const names = navigationNames(app.navigation, (item) => item.requiresObject); - objects = names.length > 0 ? metadata.readObjectScope!({ names }).served : NONE_SERVED; + if (names.length > 0) { + const scope = metadata.readObjectScope!({ names }) as { served: unknown; views?: unknown }; + objects = scope.served; + views = scope.views; + } else { + objects = NONE_SERVED; + views = NONE_SERVED; + } } else { objects = metadata.getItemsByType('object'); } - return servedViewReads(payloadOf(metadata.getItemsByType('view')), payloadOf(objects)); + if (views === undefined) views = metadata.getItemsByType('view'); + return servedViewReads(payloadOf(views), payloadOf(objects)); } /** diff --git a/ui/packages/app-shell/src/layout/AppHeader.tsx b/ui/packages/app-shell/src/layout/AppHeader.tsx index 4ee69bc8c04..6aaf1a3e781 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -88,6 +88,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { useBundledReads } from '../providers/bundledReads.js'; import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils/index.js'; import { getIcon } from '../utils/getIcon.js'; import { useNavigationContext } from '../context/NavigationContext.js'; @@ -476,8 +477,13 @@ export function AppHeader({ const metadata = useMetadata(); const metadataApps = metadata.apps; // The views a server read served, already translated — `/meta/view`, and the - // object document's own `listViews` (objectui#11295, objectui#11336). - const servedViews = useServedViewItems(); + // object document's own `listViews` (objectui#11295, objectui#11336). Read + // only on an object's view route, the one title that labels a view + // (objectstack#28645): any other route starts no view read for it. + const servedViews = useServedViewItems(isApp && location.pathname.split('/').filter(Boolean)[3] === 'view'); + // objectstack#28645 — the dashboard or page the phone title names, read from + // the app bundle first; the list below only for one the bundle does not hold. + const bundledReads = useBundledReads(); const { currentAppName, recordTitle } = useNavigationContext(); /** @@ -679,7 +685,13 @@ export function AppHeader({ if (pathParts[3]) { const dashboardName = pathParts[3]; // ADR-0048 Phase 2 — prefer the current app's package (container-scoped). - const dashboardDef = preferLocal(metadata.dashboards as any[], dashboardName, (currentApp as any)?._packageId); + const bundledDashboard = bundledReads?.item('dashboard', dashboardName, (currentApp as any)?._packageId); + const dashboardDef = + bundledDashboard?.state === 'hit' + ? bundledDashboard.value + : bundledDashboard?.state === 'pending' + ? undefined + : preferLocal(metadata.dashboards as any[], dashboardName, (currentApp as any)?._packageId); // A dashboard the `/meta` read served carries its label already // translated (a published edit kept over the packaged catalog), so it // is drawn as given — the page header draws it the same way. Only the @@ -698,7 +710,13 @@ export function AppHeader({ // above its list. The label below names it in the phone bar only. if (pathParts[3]) { const pageName = pathParts[3]; - const pageDef = preferLocal(metadata.pages as any[], pageName, (currentApp as any)?._packageId); + const bundledPage = bundledReads?.item('page', pageName, (currentApp as any)?._packageId); + const pageDef = + bundledPage?.state === 'hit' + ? bundledPage.value + : bundledPage?.state === 'pending' + ? undefined + : preferLocal(metadata.pages as any[], pageName, (currentApp as any)?._packageId); // A page's `label` may be an inline locale map; drawn raw, the phone // bar's title blanked the page (objectui#13488). const fallback = metadataText(pageDef?.label, language) ?? humanizeSlug(pageName); diff --git a/ui/packages/app-shell/src/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index 58b76f280cd..e667647fb38 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -8,7 +8,14 @@ import { } from 'react'; import { expandViewContainer } from '@objectstack/spec/ui'; import { ActiveOrganizationStorage, TokenStorage, useAuth } from '@object-ui/auth'; -import { isMetaItemAbsentError, MetadataNotFoundError, type ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { + isMetaItemAbsentError, + MetadataNotFoundError, + type AppBundleEntry, + type AppBundleItems, + type AppBundlePeek, + type ObjectStackAdapter, +} from '@object-ui/data-objectstack'; import { isActionSwitchedOff, markSwitchedOffActions, markSwitchedOffActionsByObject, normalizeSchemaReferenceKeys } from '@object-ui/core'; import { errorCodeIs } from '@object-ui/types'; import { resolveInlineMode } from '@object-ui/plugin-form'; @@ -16,6 +23,13 @@ import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCache import { usePreviewDrafts } from '../preview/PreviewModeContext.js'; import { createConsoleMetadataClient } from '../views/metadata-admin/metadataClientFactory.js'; import { subscribeCanvasInvalidate, subscribeMetadataRefresh } from '../assistant/assistantBus.js'; +import { + BUNDLE_MISS, + BUNDLE_PENDING, + BundledReadsCtx, + type BundledRead, + type BundledReads, +} from './bundledReads.js'; // `MetadataTypeStatus` is `@object-ui/react`'s, the one authority: the status // `MetadataContextValue.getTypeStatus` returns is the same four words this @@ -130,7 +144,14 @@ interface MetadataProviderProps { // --------------------------------------------------------------------------- const DEFAULT_TTL_MS = 5 * 60 * 1000; -const EAGER_TYPES = ['app', 'view'] as const; +/** + * The types read at mount. `view` is not one of them (objectstack#28645): the + * views an app page composes come from the app bundle when it holds every + * object composed (`BundledReads.viewsFor`), and the `/meta/view` list is read + * only where it does not — beside the whole object registry (`ensureType`), + * or for a scope holding an object outside the bundle. + */ +const EAGER_TYPES = ['app'] as const; /** * How long a FAILED type stays un-retried (objectui#4042). @@ -702,6 +723,79 @@ export function attachInlineSubforms(objects: any[]): any[] { */ const COMPOSED_OBJECTS = new WeakMap(); +// --------------------------------------------------------------------------- +// objectstack#28645 — bundle-first reads, answered in render +// --------------------------------------------------------------------------- + +/** + * One copy of each bundled document, handed out read after read so a reader's + * memo on it holds: the bundle's own entry is never handed out, as the + * adapter's by-name read never hands it out either. + */ +const BUNDLED_HITS = new WeakMap>(); +function bundledHit(entry: AppBundleEntry): BundledRead { + let hit = BUNDLED_HITS.get(entry); + if (!hit) { + hit = Object.freeze({ state: 'hit' as const, value: structuredClone(entry.item) }); + BUNDLED_HITS.set(entry, hit); + } + return hit; +} + +/** The bundle's view documents, one array per held answer (`composeObjects` keys on its identity). */ +const BUNDLED_VIEWS = new WeakMap>(); +function bundledViews(items: AppBundleItems): BundledRead { + let hit = BUNDLED_VIEWS.get(items); + if (!hit) { + const views = Array.from(items.get('view')?.values() ?? [], (entry) => structuredClone(entry.item)); + hit = Object.freeze({ state: 'hit' as const, value: views }); + BUNDLED_VIEWS.set(items, hit); + } + return hit; +} + +/** What a reader that has no views to compose with yet holds. */ +const NO_VIEWS: unknown[] = []; + +/** The names of a list's named items. */ +function itemNames(items: readonly unknown[]): string[] { + const names: string[] = []; + for (const it of items) if (isNamedItem(it)) names.push(it.name); + return names; +} + +/** The object a view document belongs to, as `mergeViewsIntoObjects` reads it. */ +function viewObjectName(view: any): string | undefined { + if (isViewItem(view)) return view.object; + const name = view?.name || view?.list?.data?.object || view?.form?.data?.object; + return typeof name === 'string' && name ? name : undefined; +} + +/** + * The bundle's views, plus the listed views of the objects the bundle does not + * hold (objectstack#28645): an object the bundle holds always composes with the + * bundle's views, so it does not change when the `/meta/view` list lands. One + * array per (bundle views, list) pair, so `composeObjects` keeps its identity + * across renders. + */ +const BUNDLED_WITH_LISTED = new WeakMap(); +function bundledWithListed( + bundled: unknown[], + bundledObjects: ReadonlyMap | undefined, + listed: unknown[], +): unknown[] { + if (listed.length === 0) return bundled; + const cached = BUNDLED_WITH_LISTED.get(bundled); + if (cached && cached.listed === listed) return cached.views; + const rest = listed.filter((view) => { + const object = viewObjectName(view); + return !!object && !bundledObjects?.has(object); + }); + const views = rest.length > 0 ? [...bundled, ...rest] : bundled; + BUNDLED_WITH_LISTED.set(bundled, { listed, views }); + return views; +} + function composeObjects(objs: unknown[], views: unknown[]): unknown[] { const cached = COMPOSED_OBJECTS.get(objs); if (cached && cached.views === views) return cached.composed; @@ -854,6 +948,77 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: } }, []); + // ── objectstack#28645 — the app bundle, read in render ─────────────────── + // + // `AppContent` enters the app the URL names from an effect, and a parent's + // effects run after its children's, so a reader rendering in the same + // commit can find no app entered yet. That is not an answer: the read stays + // pending, and this provider's own effect (below, after every descendant's) + // looks again — an app entered by then is waited for, none means no bundle + // will answer, and the readers read their lists. + const bundleNoneAskedRef = useRef(false); + const bundleNoneFinalRef = useRef(false); + const watchedBundleLoadsRef = useRef(new WeakSet>()); + const peekBundle = useCallback((): AppBundlePeek | null => { + // Draft preview reads its own world; the bundle is the published one. + if (previewClientRef.current) return null; + const host = adapterRef.current as Partial> | undefined; + if (typeof host?.peekAppBundle !== 'function') return null; + let peek: AppBundlePeek; + try { + peek = host.peekAppBundle(); + } catch { + return null; + } + if (peek.status === 'none') { + if (bundleNoneFinalRef.current) return null; + bundleNoneAskedRef.current = true; + return peek; + } + // A load on the wire re-renders the readers when it lands, once per load. + if (peek.settled && !watchedBundleLoadsRef.current.has(peek.settled)) { + watchedBundleLoadsRef.current.add(peek.settled); + void peek.settled.then(bump, bump); + } + return peek; + }, [bump]); + useEffect(() => { + if (!bundleNoneAskedRef.current) return; + bundleNoneAskedRef.current = false; + const peek = peekBundle(); + if (peek && peek.status !== 'none') return; + // `peekBundle` re-marked the ask; none is now the answer. + bundleNoneAskedRef.current = false; + bundleNoneFinalRef.current = true; + bump(); + }); + const readBundledItem = useCallback( + (type: string, name: string, ownerPackageId?: unknown): BundledRead => { + const peek = peekBundle(); + if (!peek) return BUNDLE_MISS; + if (peek.status !== 'ready') return BUNDLE_PENDING; + const entry = peek.items.get(type)?.get(name); + if (!entry) return BUNDLE_MISS; + const hit = bundledHit(entry); + if (ownerPackageId && (hit as { value?: { _packageId?: unknown } }).value?._packageId !== ownerPackageId) { + return BUNDLE_MISS; + } + return hit; + }, + [peekBundle], + ); + const readBundledViews = useCallback( + (objectNames: readonly string[]): BundledRead => { + const peek = peekBundle(); + if (!peek) return BUNDLE_MISS; + if (peek.status !== 'ready') return BUNDLE_PENDING; + const bundledObjects = peek.items.get('object'); + for (const name of objectNames) if (!bundledObjects?.has(name)) return BUNDLE_MISS; + return bundledViews(peek.items); + }, + [peekBundle], + ); + // Entering/leaving preview swaps the entire metadata source — the published // and draft-overlaid worlds must never mix in one cache. Drop everything and // let consumers refetch through the new source. @@ -892,7 +1057,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: return entry; }, []); - const ensureType = useCallback( + const ensureOwnType = useCallback( (type: string): Promise => { const entry = getEntry(type); @@ -986,6 +1151,18 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: [bump, getEntry, ttlMs], ); + // The whole object registry is composed with the whole `/meta/view` list, so + // the two are read together and the registry answers once both have: with + // `view` no longer read at mount (objectstack#28645), a reader of `objects` + // never sees a registry whose views are still on the wire. + const ensureType = useCallback( + (type: string): Promise => { + if (type !== 'object') return ensureOwnType(type); + return Promise.all([ensureOwnType('object'), ensureOwnType('view')]).then(([items]) => items); + }, + [ensureOwnType], + ); + const readItem = useCallback( (type: string, name: string): Promise => { const entry = getEntry(type); @@ -1414,6 +1591,49 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: [ensureType, getEntry], ); + /** + * The views to compose a scope's objects with (objectstack#28645), and + * whether they are in hand. `asked` are the names the scope's reader asked + * for (a names-only scope: every object in it). + * + * - The app bundle in hand: an object it holds composes with its views (it + * carries every view of every object it holds). When an asked name is + * outside it, the scope also reads the `/meta/view` list for the objects + * the bundle does not hold, and settles once that is in hand. A package + * object outside the bundle that no reader asked by name composes from a + * list already in hand, and asks for none: a reader of its views names it. + * - The bundle still loading, or the app about to be entered in this + * commit: a list already in hand answers, so a scope never unsettles + * because an app was entered after it; otherwise the scope waits. + * - No bundle (no app entered, or a host without one): the list, as before. + */ + const viewsForObjects = useCallback( + (asked: readonly string[]): { views: unknown[]; settled: boolean } => { + const listed = getEntry('view'); + const peek = peekBundle(); + if (peek?.status === 'ready') { + const bundledObjects = peek.items.get('object'); + const fromBundle = (bundledViews(peek.items) as { value: unknown[] }).value; + const outside = asked.some((name) => !bundledObjects?.has(name)); + if (!outside) { + const views = listed.status === 'ready' ? bundledWithListed(fromBundle, bundledObjects, listed.items) : fromBundle; + return { views, settled: true }; + } + const list = readType('view'); + const status = getEntry('view').status; + return { views: bundledWithListed(fromBundle, bundledObjects, list), settled: status === 'ready' || status === 'error' }; + } + if (listed.status === 'ready') return { views: listed.items, settled: true }; + // `none` here is an app not entered YET: `peekBundle` answers null once + // the commit has settled without one. + if (peek) return { views: NO_VIEWS, settled: false }; + const views = readType('view'); + const status = getEntry('view').status; + return { views, settled: status === 'ready' || status === 'error' }; + }, + [getEntry, peekBundle, readType], + ); + // 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 @@ -1421,17 +1641,19 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: // it and nothing is requested. With `names` and no `packageId` it is the // names-only scope (objectui#12980): no package list, each name by name. const readObjectScope = useCallback( - (scope: { packageId?: string; names?: readonly string[] }) => { + (scope: { packageId?: string; names?: readonly string[]; views?: false }) => { const namesOnly = !scope.packageId && !!scope.names && scope.names.length > 0; const packageId = scope.packageId || (namesOnly ? NAMES_ONLY_SCOPE : undefined); if (packageId === undefined) { const status = getEntry('object').status; const served = readType('object'); + const views = readType('view'); return { - objects: composeObjects(served, readType('view')) as any[], + objects: composeObjects(served, views) as any[], served, missing: NO_NAMES, - settled: status === 'ready' || status === 'error', + settled: (status === 'ready' || status === 'error') && getEntry('view').status !== 'loading', + views, }; } let entry = scopesRef.current.get(packageId); @@ -1495,16 +1717,36 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: // code. Both ride beside the published shape: this package's own // readers read them (`appObjectScope.ts`), other readers never see them. const atRest = entry.answered && namesSettled; + // objectstack#28645 — the scope's views (see `viewsForObjects`). The + // scope is not settled until they are in hand, so a route never draws its + // objects without the views they carry. `views` rides beside the + // published shape for this package's own readers (`useServedViewItems`). + // `views: false`, this package's own hand-off too, is a reader that only + // asks which objects there are (`useObjectPresence`): it reads no views, + // and its `objects` are the served items, not composed. + if (scope.views === false) { + return { + objects: entry.items, + served: entry.items, + missing: entry.missing as readonly string[], + settled: atRest && failedNames.length === 0, + failed: atRest && failedNames.length > 0 ? (failedNames as readonly string[]) : NO_NAMES, + refused: entry.refused, + views: NO_VIEWS, + }; + } + const scopeViews = viewsForObjects(namesOnly ? itemNames(entry.items) : (scope.names ?? NO_NAMES)); return { - objects: composeObjects(entry.items, readType('view')) as any[], + objects: composeObjects(entry.items, scopeViews.views) as any[], served: entry.items, missing: entry.missing as readonly string[], - settled: atRest && failedNames.length === 0, + settled: atRest && failedNames.length === 0 && scopeViews.settled, failed: atRest && failedNames.length > 0 ? (failedNames as readonly string[]) : NO_NAMES, refused: entry.refused, + views: scopeViews.views, }; }, - [fetchScopeList, fetchScopeName, getEntry, readType], + [fetchScopeList, fetchScopeName, getEntry, readType, viewsForObjects], ); const value = useMemo(() => { @@ -1537,13 +1779,31 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: readObjectScope, // Pure read — must NOT kick a fetch, so render-phase status checks // can't recurse into ensureType. - getTypeStatus: (type: string) => getEntry(type).status, + // objectstack#28645 — the registry answers once its views have too + // (`ensureType` reads them together). + getTypeStatus: (type: string) => { + const status = getEntry(type).status; + if (type === 'object' && (status === 'ready' || status === 'error') && getEntry('view').status === 'loading') { + return 'loading'; + } + return status; + }, }; return base; }, [version, initialLoading, initialError, ensureType, getItem, getEntry, refresh, invalidate, readType, readObjectScope]); - return {children}; + // A new identity per version, so a reader re-renders when the bundle lands. + const bundledReads = useMemo(() => { + void version; + return { item: readBundledItem, viewsFor: readBundledViews }; + }, [version, readBundledItem, readBundledViews]); + + return ( + + {children} + + ); } export function useMetadataType(type: string): { items: any[]; loading: boolean; error: Error | null } { diff --git a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx new file mode 100644 index 00000000000..760267c27a0 --- /dev/null +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx @@ -0,0 +1,381 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28645 — an app page reads its views, page and dashboard from + * the app bundle, and the `/meta/view`, `/meta/page`, `/meta/dashboard` lists + * only for what the bundle does not hold. + * + * Measured on cloud's console before this card: a warm open of an app page + * asked the bundle AND the three lists the bundle already answers. The double + * below serves the list and by-name doors and a bundle the test controls + * (`peekAppBundle`, as the real adapter answers it), recording every list read. + * + * What these pins hold: + * + * - the mount reads the app list and NOT the view list; + * - a scope whose objects the bundle all holds composes them with the + * bundle's views and never asks `view`; it is not settled while the bundle + * is on the wire, and settles when it lands; + * - CONTROL: a scope holding an object outside the bundle reads the `view` + * list, and the whole registry is read with it, answering only once both + * have; + * - a bundled page is a hit, read for read the same document; a name the + * bundle lacks, or a same-named page another package owns, is a miss; + * - an app entered in the same commit (a descendant's effect, as + * `AppContent` enters it) is waited for; no app entered at all is a miss + * once the commit has settled, so the readers read their lists. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { useEffect } from 'react'; +import { MetadataProvider, useMetadata } from '../MetadataProvider'; +import { bundledFirst, useBundledItem, useBundledReads, type BundledRead, type BundledReads } from '../bundledReads'; +import { useServedViewItems } from '../../hooks/useServedViewItems'; + +type Ctx = ReturnType; +type Doc = { name: string; [key: string]: unknown }; + +const PKG = 'app.b28645'; +const OBJECTS: Doc[] = [ + { name: 'b28645_plan', _packageId: PKG, fields: { name: { type: 'text' } } }, + { name: 'b28645_price', _packageId: PKG, fields: { plan: { type: 'lookup', reference: 'b28645_plan' } } }, + { name: 'b28645_outside', _packageId: PKG, fields: { name: { type: 'text' } } }, +]; +const VIEWS: Doc[] = [ + { name: 'b28645_plan.all', object: 'b28645_plan', viewKind: 'list', label: 'All plans', isDefault: true, config: { type: 'grid', columns: ['name'] } }, + { name: 'b28645_outside.all', object: 'b28645_outside', viewKind: 'list', label: 'All outside', isDefault: true, config: { type: 'grid', columns: ['name'] } }, +]; +const PAGES: Doc[] = [{ name: 'b28645_pricing', _packageId: PKG, label: 'Pricing' }]; + +/** The bundle's items, keyed as the adapter's `AppBundleItems` keys them. */ +function bundleItems(over: { pages?: Doc[] } = {}) { + const items = new Map }>>(); + const put = (type: string, docs: Doc[]) => { + const byName = new Map(); + for (const doc of docs) byName.set(doc.name, { item: structuredClone(doc), envelope: { type, name: doc.name, item: doc } }); + items.set(type, byName); + }; + put('object', OBJECTS.slice(0, 2)); + put('view', VIEWS.slice(0, 1)); + put('page', over.pages ?? PAGES); + put('dashboard', [{ name: 'b28645_home', _packageId: PKG, label: 'Home' }]); + return items; +} + +interface Bundle { + state: { status: 'none' } | { status: 'pending'; settled: Promise } | { status: 'ready'; items: ReturnType }; + enter(): void; + land(items?: ReturnType): Promise; +} + +function makeBundle(): Bundle { + let resolve: () => void = () => {}; + const bundle: Bundle = { + state: { status: 'none' }, + enter() { + if (bundle.state.status !== 'none') return; + bundle.state = { status: 'pending', settled: new Promise((r) => { resolve = r; }) }; + }, + async land(items = bundleItems()) { + bundle.state = { status: 'ready', items }; + resolve(); + await Promise.resolve(); + }, + }; + return bundle; +} + +function makeAdapter(calls: string[], bundle: Bundle | null, viewsHeld?: Promise) { + return { + clearCache: () => {}, + invalidateCache: () => {}, + ...(bundle ? { peekAppBundle: () => bundle.state } : {}), + getObjectSchema: (name: string) => { + calls.push(`object/${name}`); + const item = OBJECTS.find((o) => o.name === name); + return item ? Promise.resolve(structuredClone(item)) : Promise.reject(Object.assign(new Error('nf'), { httpStatus: 404 })); + }, + getClient: () => ({ + meta: { + getItems: (type: string, options?: { packageId?: string }) => { + calls.push(options?.packageId ? `${type}?package=${options.packageId}` : type); + const all = type === 'object' ? OBJECTS : type === 'view' ? VIEWS : type === 'page' ? PAGES : []; + const answer = { type, items: structuredClone(all) }; + return type === 'view' && viewsHeld ? viewsHeld.then(() => answer) : Promise.resolve(answer); + }, + getItem: (type: string, name: string) => { + calls.push(`${type}/${name}`); + return Promise.reject(Object.assign(new Error('nf'), { httpStatus: 404 })); + }, + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function mount(opts: { bundle?: Bundle | null; enterInEffect?: Bundle; viewsHeld?: Promise } = {}) { + const calls: string[] = []; + const bundle = opts.bundle === undefined ? makeBundle() : opts.bundle; + let ctx: Ctx | null = null; + let reads: BundledReads | null = null; + let page: BundledRead | null = null; + function Probe() { + ctx = useMetadata(); + reads = useBundledReads(); + page = useBundledItem('page', 'b28645_pricing', PKG); + return null; + } + // Enters the app from an effect, as `AppContent` does: a descendant's + // effect runs before the provider's own. + function Enter() { + useEffect(() => opts.enterInEffect?.enter(), []); + return null; + } + render( + + + + , + ); + // `bundle` is null only for the host-without-a-bundle case, which never reads it. + return { calls, bundle: bundle as Bundle, ctx: () => ctx as unknown as Ctx, reads: () => reads as BundledReads, page: () => page as BundledRead }; +} + +const lists = (calls: string[]) => calls.filter((c) => !c.includes('/')); +const scopeNames = ['b28645_plan', 'b28645_price']; + +afterEach(() => cleanup()); + +describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { + it('reads the app list at mount, and not the view list', async () => { + const m = mount(); + await waitFor(() => expect(m.calls).toContain('app')); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(lists(m.calls)).toEqual(['app']); + }); + + it('composes a scope the bundle holds whole with its views, and never asks `view`', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await waitFor(() => expect(m.calls).toContain('app')); + let scope = m.ctx().readObjectScope!({ names: scopeNames }); + await waitFor(() => { + scope = m.ctx().readObjectScope!({ names: scopeNames }); + expect(scope.served).toHaveLength(2); + }); + // Its objects are answered, its views are not yet: not settled. + expect(scope.settled).toBe(false); + await act(async () => { await m.bundle.land(); }); + await waitFor(() => { + scope = m.ctx().readObjectScope!({ names: scopeNames }); + expect(scope.settled).toBe(true); + }); + const plan = (scope.objects as Doc[]).find((o) => o.name === 'b28645_plan') as { listViews?: Record }; + expect(plan.listViews?.['b28645_plan.all']?.label).toBe('All plans'); + expect(lists(m.calls)).toEqual(['app']); + }); + + it('CONTROL: a scope holding an object outside the bundle reads the `view` list', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await act(async () => { await m.bundle.land(); }); + const withOutside = ['b28645_plan', 'b28645_outside']; + await waitFor(() => { + const scope = m.ctx().readObjectScope!({ names: withOutside }); + expect(scope.settled).toBe(true); + }); + const scope = m.ctx().readObjectScope!({ names: withOutside }); + const outside = (scope.objects as Doc[]).find((o) => o.name === 'b28645_outside') as { listViews?: Record }; + expect(outside.listViews?.['b28645_outside.all']).toBeDefined(); + expect(lists(m.calls)).toEqual(['app', 'view']); + }); + + it('composes a package scope from the bundle when every name asked is bundled, though the package holds more', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await act(async () => { await m.bundle.land(); }); + let scope = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + await waitFor(() => { + scope = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + expect(scope.settled).toBe(true); + }); + // The package list holds `b28645_outside` too; no reader asked for it. + expect(scope.served).toHaveLength(3); + const plan = (scope.objects as Doc[]).find((o) => o.name === 'b28645_plan') as { listViews?: Record }; + expect(plan.listViews?.['b28645_plan.all']).toBeDefined(); + expect(lists(m.calls)).toEqual(['app', `object?package=${PKG}`]); + // The same composition, read after read. + expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).objects).toBe(scope.objects); + }); + + it('CONTROL: a package scope asking an object the bundle lacks reads the `view` list', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await act(async () => { await m.bundle.land(); }); + const asked = ['b28645_plan', 'b28645_outside']; + await waitFor(() => { + const scope = m.ctx().readObjectScope!({ packageId: PKG, names: asked }); + expect(scope.settled).toBe(true); + }); + const scope = m.ctx().readObjectScope!({ packageId: PKG, names: asked }); + const outside = (scope.objects as Doc[]).find((o) => o.name === 'b28645_outside') as { listViews?: Record }; + expect(outside.listViews?.['b28645_outside.all']).toBeDefined(); + expect(lists(m.calls)).toEqual(['app', `object?package=${PKG}`, 'view']); + }); + + it('composes an unasked package object from a `view` list already in hand', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await act(async () => { await m.bundle.land(); }); + await act(async () => { await m.ctx().ensureType!('view'); }); + await waitFor(() => { + expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).settled).toBe(true); + }); + const scope = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + const byName = (name: string) => (scope.objects as Doc[]).find((o) => o.name === name) as { listViews?: Record }; + expect(byName('b28645_plan').listViews?.['b28645_plan.all']).toBeDefined(); + expect(byName('b28645_outside').listViews?.['b28645_outside.all']).toBeDefined(); + expect(lists(m.calls)).toEqual(['app', 'view', `object?package=${PKG}`]); + }); + + it('a scope settled on the view list stays settled while an app entered after it loads its bundle', async () => { + const m = mount(); + await act(async () => { await m.ctx().ensureType!('view'); }); + await waitFor(() => { + expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).settled).toBe(true); + }); + const before = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + act(() => m.bundle.enter()); + const during = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + expect(during.settled).toBe(true); + expect(during.objects).toBe(before.objects); + await act(async () => { await m.bundle.land(); }); + // Landed: a bundled object composes from the bundle, the others from the list in hand. + const after = m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }); + expect(after.settled).toBe(true); + const byName = (name: string) => (after.objects as Doc[]).find((o) => o.name === name) as { listViews?: Record }; + expect(byName('b28645_plan').listViews?.['b28645_plan.all']).toBeDefined(); + expect(byName('b28645_outside').listViews?.['b28645_outside.all']).toBeDefined(); + expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).objects).toBe(after.objects); + }); + + it('a bundled object keeps the bundle\'s views while a scope that asks an outside object reads the list', async () => { + let release: () => void = () => {}; + const m = mount({ viewsHeld: new Promise((r) => { release = r; }) }); + act(() => m.bundle.enter()); + await act(async () => { await m.bundle.land(); }); + const asked = ['b28645_plan', 'b28645_outside']; + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: PKG, names: asked }).served).toHaveLength(3)); + // The list is on the wire: not settled, yet the bundled object already carries its views. + const loading = m.ctx().readObjectScope!({ packageId: PKG, names: asked }); + const planWhileLoading = (loading.objects as Doc[]).find((o) => o.name === 'b28645_plan') as { listViews?: Record }; + expect(planWhileLoading.listViews?.['b28645_plan.all']).toBeDefined(); + expect(loading.settled).toBe(false); + await act(async () => { release(); }); + await waitFor(() => expect(m.ctx().readObjectScope!({ packageId: PKG, names: asked }).settled).toBe(true)); + }); + + it('a scope read with `views: false` asks which objects there are and never reads the view list', async () => { + const m = mount(); + const read = (names: string[]) => + (m.ctx().readObjectScope as unknown as (s: { packageId: string; names: string[]; views: false }) => ReturnType>)( + { packageId: PKG, names, views: false }, + ); + // `b28645_outside` is not in the bundle, and no bundle was even entered. + await waitFor(() => expect(read(['b28645_outside']).settled).toBe(true)); + const scope = read(['b28645_outside']); + expect((scope.objects as Doc[]).map((o) => o.name)).toContain('b28645_outside'); + expect(scope.objects).toBe(scope.served); + expect(lists(m.calls)).toEqual(['app', `object?package=${PKG}`]); + }); + + it('CONTROL: the whole registry reads the view list with it, and answers once both have', async () => { + const m = mount(); + await waitFor(() => expect(m.calls).toContain('app')); + act(() => { void m.ctx().objects; }); + expect(lists(m.calls)).toEqual(['app', 'object', 'view']); + await waitFor(() => expect(m.ctx().getTypeStatus!('object')).toBe('ready')); + const plan = (m.ctx().objects as Doc[]).find((o) => o.name === 'b28645_plan') as { listViews?: Record }; + expect(plan.listViews?.['b28645_plan.all']).toBeDefined(); + }); + + it('answers a bundled page from the bundle, the same document read after read', async () => { + const m = mount(); + act(() => m.bundle.enter()); + expect(m.page().state).toBe('pending'); + await act(async () => { await m.bundle.land(); }); + await waitFor(() => expect(m.page().state).toBe('hit')); + const first = m.page(); + expect(first.state === 'hit' && first.value.label).toBe('Pricing'); + expect(m.reads().item('page', 'b28645_pricing', PKG)).toBe(first); + expect(lists(m.calls)).toEqual(['app']); + }); + + it('a name the bundle lacks, or a same-named page another package owns, is a miss', async () => { + const m = mount(); + act(() => m.bundle.enter()); + await act(async () => { + await m.bundle.land(bundleItems({ pages: [{ name: 'b28645_pricing', _packageId: 'app.other', label: 'Theirs' }] })); + }); + await waitFor(() => expect(m.page().state).toBe('miss')); + expect(m.reads().item('page', 'b28645_nowhere').state).toBe('miss'); + // Without an owning package the bundle's item is taken as it is. + expect(m.reads().item('page', 'b28645_pricing').state).toBe('hit'); + }); + + it('waits for an app a descendant enters in the same commit', async () => { + const bundle = makeBundle(); + const m = mount({ bundle, enterInEffect: bundle }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(m.page().state).toBe('pending'); + await act(async () => { await bundle.land(); }); + await waitFor(() => expect(m.page().state).toBe('hit')); + }); + + it('with no app entered, reads are a miss once the commit settles', async () => { + const m = mount(); + await waitFor(() => expect(m.page().state).toBe('miss')); + }); + + it('a host adapter without the bundle answers every read a miss', async () => { + const m = mount({ bundle: null }); + expect(m.page().state).toBe('miss'); + expect(m.reads().viewsFor(scopeNames).state).toBe('miss'); + }); + + it('bundledFirst reads the list only for a name the bundle does not hold', async () => { + const m = mount(); + act(() => m.bundle.enter()); + let listReads = 0; + const list = () => { + listReads += 1; + return [{ name: 'b28645_elsewhere', label: 'Elsewhere' }]; + }; + expect(bundledFirst(m.reads(), 'dashboard', 'b28645_home', list)).toBeUndefined(); + await act(async () => { await m.bundle.land(); }); + await waitFor(() => expect(bundledFirst(m.reads(), 'dashboard', 'b28645_home', list)?.label).toBe('Home')); + expect(listReads).toBe(0); + expect(bundledFirst(m.reads(), 'dashboard', 'b28645_elsewhere', list)?.label).toBe('Elsewhere'); + expect(listReads).toBe(1); + expect(bundledFirst(null, 'dashboard', 'b28645_elsewhere', list)?.label).toBe('Elsewhere'); + }); + + it('useServedViewItems(false) reads neither list', async () => { + const calls: string[] = []; + let served: ReturnType | null = null; + function Probe() { + served = useServedViewItems(false); + return null; + } + render( + + + , + ); + await waitFor(() => expect(calls).toContain('app')); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(lists(calls)).toEqual(['app']); + expect(served!.views).toHaveLength(0); + expect(served!.objects).toHaveLength(0); + }); +}); diff --git a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.namesOnlyScope-12980.test.tsx b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.namesOnlyScope-12980.test.tsx index e0ead84a8e7..9b495610a87 100644 --- a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.namesOnlyScope-12980.test.tsx +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.namesOnlyScope-12980.test.tsx @@ -108,7 +108,7 @@ afterEach(() => cleanup()); describe('MetadataProvider.readObjectScope, the names-only scope (objectui#12980)', () => { it('asks each name by name, never the package list or the whole registry', async () => { const m = mount(); - await waitFor(() => expect(m.server.calls).toContain('view')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ names: ['crm_case', 'sys_user'] }); }); @@ -124,7 +124,7 @@ describe('MetadataProvider.readObjectScope, the names-only scope (objectui#12980 it('is not settled until every name answers, and lists a name the server does not answer as missing', async () => { const m = mount(); - await waitFor(() => expect(m.server.calls).toContain('view')); + await waitFor(() => expect(m.server.calls).toContain('app')); m.server.holdByName = true; act(() => { m.ctx().readObjectScope!({ names: ['crm_case', 'no_such_object'] }); @@ -139,7 +139,7 @@ describe('MetadataProvider.readObjectScope, the names-only scope (objectui#12980 it('with the whole registry loaded, is cut from it and asks for nothing', async () => { const m = mount(); - await waitFor(() => expect(m.server.calls).toContain('view')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { void m.ctx().ensureType('object'); }); @@ -156,7 +156,7 @@ describe('MetadataProvider.readObjectScope, the names-only scope (objectui#12980 it('a refresh asks its names again, and no package list', async () => { const m = mount(); - await waitFor(() => expect(m.server.calls).toContain('view')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ names: ['crm_case'] }); }); @@ -171,7 +171,7 @@ describe('MetadataProvider.readObjectScope, the names-only scope (objectui#12980 it('CONTROL: no package id and no names is the whole registry, and so is an empty names list', async () => { const m = mount(); - await waitFor(() => expect(m.server.calls).toContain('view')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ names: [] }); }); diff --git a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx index b9bcab6e3f9..4e3045dca11 100644 --- a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.objectScope-12811.test.tsx @@ -116,7 +116,7 @@ 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ packageId: CRM }); }); @@ -133,7 +133,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); m.server.holdByName = true; act(() => { m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_account', 'crm_case'] }); @@ -159,7 +159,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }); }); @@ -181,7 +181,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { m.ctx().readObjectScope!({ packageId: CRM, names: ['cloud_only'] }); }); @@ -191,7 +191,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { void m.ctx().objects; }); @@ -206,7 +206,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); act(() => { void m.ctx().objects; }); @@ -220,7 +220,7 @@ describe('MetadataProvider.readObjectScope (objectui#12811)', () => { 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')); + await waitFor(() => expect(m.server.calls).toContain('app')); const read = () => m.ctx().readObjectScope!({ packageId: CRM, names: ['crm_case'] }); act(() => { read(); diff --git a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx index 7d38c61cc57..3e74c879c21 100644 --- a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx @@ -151,6 +151,13 @@ afterEach(() => { vi.unstubAllGlobals(); }); +/** Starts the provider's own `/meta/view` read, as a reader of the whole registry does. */ +function readViewList(ctx: () => ReturnType) { + act(() => { + void ctx().ensureType!('view'); + }); +} + describe('one request per object document across both caches (objectui#13044)', () => { it('provider first: getObjectSchema reads what the provider asked for', async () => { const { server, adapter } = makeServer(); @@ -205,9 +212,12 @@ describe('one request per object document across both caches (objectui#13044)', }); describe('the published view list: one request per open (objectui#13044, objectui#13057)', () => { - it('console order: the eager read lands, then the object page reads the list and the overrides together', async () => { + it('console order: the provider view read lands, then the object page reads the list and the overrides together', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); expect(count(server.calls, VIEW_URL)).toBe(1); // ObjectView starts both reads in one commit (`listViews` and @@ -222,10 +232,13 @@ describe('the published view list: one request per open (objectui#13044, objectu expect(count(server.calls, VIEW_URL)).toBe(1); }); - it('a reader that asks while the eager read is on the wire joins it', async () => { + it('a reader that asks while the provider view read is on the wire joins it', async () => { const { server, adapter } = makeServer(); server.hold.add(VIEW_URL); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); const views = adapter.listViews('account'); const overrides = adapter.listViewOverrides('account'); @@ -269,6 +282,9 @@ describe('controls (objectui#13044)', () => { it('the list is settled within an open, and a provider re-read asks again and is what the next list read sees (objectui#13057)', async () => { const { server, adapter } = makeServer(); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); await adapter.listViews('account'); await adapter.listViews('account'); @@ -306,6 +322,9 @@ describe('controls (objectui#13044)', () => { ]) { const { server, adapter } = makeServer(); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(ctx().getTypeStatus!('view')).toBe('ready')); await adapter.listViews('account'); expect(count(server.calls, VIEW_URL)).toBe(1); @@ -323,6 +342,9 @@ describe('controls (objectui#13044)', () => { const { server, adapter } = makeServer(); server.hold.add(VIEW_URL); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); // Joins the eager read on the wire (after its own discovery read). const before = adapter.listViews('account'); @@ -339,6 +361,9 @@ describe('controls (objectui#13044)', () => { const { server, adapter } = makeServer(); server.hold.add(VIEW_URL); const ctx = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand (the + // whole registry, or a scope outside the app bundle), no longer at mount. + readViewList(ctx); await waitFor(() => expect(count(server.calls, VIEW_URL)).toBe(1)); const before = adapter.listViews('account'); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx index b34660e1f7a..5ef23a1521d 100644 --- a/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx @@ -114,6 +114,10 @@ describe('a switch drops the adapter metadata the provider read through it (obje authState.activeOrganization = { id: 'org_a' }; hold.add(VIEW_URL); const m = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand, no longer at mount. + act(() => { + void m.ctx().ensureType!('view'); + }); await waitFor(() => expect(count(calls, VIEW_URL)).toBe(1)); authState.activeOrganization = { id: 'org_b' }; @@ -140,9 +144,13 @@ describe('a switch drops the adapter metadata the provider read through it (obje authState.activeOrganization = { id: 'org_a' }; authState.user = { id: 'user_a' }; const m = mount(adapter); + // objectstack#28645 — the provider reads the view list on demand, no longer at mount. + act(() => { + void m.ctx().ensureType!('view'); + }); await waitFor(() => expect(count(calls, VIEW_URL)).toBe(1)); await waitFor(() => expect(m.ctx().getTypeStatus!('view')).toBe('ready')); - // Settled within the open: the eager read's answer serves the list read. + // Settled within the open: the provider read's answer serves the list read. await adapter.listViews('account'); expect(count(calls, VIEW_URL)).toBe(1); diff --git a/ui/packages/app-shell/src/providers/bundledReads.ts b/ui/packages/app-shell/src/providers/bundledReads.ts new file mode 100644 index 00000000000..b3ab7610b59 --- /dev/null +++ b/ui/packages/app-shell/src/providers/bundledReads.ts @@ -0,0 +1,81 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28645 — what `MetadataProvider` answers from the app bundle, for + * readers that must answer in render. Its own module, beside the provider, so + * a test that stands in for the provider module leaves these readers on their + * no-provider answer (a miss: every read reads its list). + */ + +import { createContext, useContext } from 'react'; + +/** + * One bundle-first read: `pending` while the app bundle that may hold it has + * not answered yet; `hit` with what the bundle holds; `miss` when no bundle + * can answer it — none was entered, draft preview, a host adapter without the + * bundle, or an item outside it. On a miss the reader reads its list exactly + * as it did before the bundle, so an item the bundle lacks is answered by the + * same read, prefer-local resolution (ADR-0048) included. + */ +export type BundledRead = + | { readonly state: 'pending' } + | { readonly state: 'hit'; readonly value: T } + | { readonly state: 'miss' }; + +export const BUNDLE_PENDING: BundledRead = Object.freeze({ state: 'pending' as const }); +export const BUNDLE_MISS: BundledRead = Object.freeze({ state: 'miss' as const }); + +/** What this provider answers from the app bundle, for readers that must answer in render. */ +export interface BundledReads { + /** + * The `type` / `name` item the active app bundle holds. With + * `ownerPackageId`, an item another package owns is a miss: the list's + * prefer-local pick (`preferLocal`) decides between same-named items. + */ + item(type: string, name: string, ownerPackageId?: unknown): BundledRead; + /** + * The view documents to compose `objectNames` with: the bundle's, when it + * holds every one of those objects (it holds every view bound to an object + * it holds); a miss otherwise, and the `/meta/view` list composes them. + */ + viewsFor(objectNames: readonly string[]): BundledRead; +} + +export const BundledReadsCtx = createContext(null); + +/** The provider's {@link BundledReads}, or `null` outside a `MetadataProvider` (every read then reads its list). */ +export function useBundledReads(): BundledReads | null { + return useContext(BundledReadsCtx); +} + +/** {@link BundledReads.item} as a hook; a miss outside a `MetadataProvider` or with no name. */ +export function useBundledItem(type: string, name: string | undefined | null, ownerPackageId?: unknown): BundledRead { + const reads = useBundledReads(); + return reads && name ? reads.item(type, name, ownerPackageId) : BUNDLE_MISS; +} + +/** + * A by-name read in a reader that answers in render (a label resolver): the + * item the bundle holds; nothing while the bundle is on the wire; else the + * first item named `name` in `list()`, which is read only then — exactly the + * lookup the reader made before the bundle. + */ +export function bundledFirst( + reads: BundledReads | null, + type: string, + name: string, + list: () => readonly unknown[] | undefined, +): any { + const bundled = reads ? reads.item(type, name) : BUNDLE_MISS; + if (bundled.state === 'hit') return bundled.value; + if (bundled.state === 'pending') return undefined; + return (list() ?? []).find( + (item) => !!item && typeof item === 'object' && (item as { name?: unknown }).name === name, + ); +} diff --git a/ui/packages/app-shell/src/views/PageView.tsx b/ui/packages/app-shell/src/views/PageView.tsx index 2e3b1ed4e7c..1285c53ec7e 100644 --- a/ui/packages/app-shell/src/views/PageView.tsx +++ b/ui/packages/app-shell/src/views/PageView.tsx @@ -34,6 +34,7 @@ import { FileText, Pencil, RotateCw } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { useMetadata } from '../providers/MetadataProvider.js'; +import { useBundledItem } from '../providers/bundledReads.js'; import { PageRecordScope, useExpressionContext } from '../providers/ExpressionProvider.js'; import { preferLocal } from '../utils/preferLocal.js'; import { appStudioRoutePath } from '../utils/appRoute.js'; @@ -306,7 +307,9 @@ export function PageView() { // ⛔ `objects` is not destructured: reading it loads the whole registry. A // page reads the objects it binds (objectui#12980), below. const metadata = useMetadata(); - const { pages, getTypeStatus, readObjectScope } = metadata; + // ⛔ `pages` is not destructured either: reading it loads the `/meta/page` + // list, which the app bundle spares (below). + const { getTypeStatus, readObjectScope } = metadata; // The objects a block asked for by name that the page does not bind. One // set for the page's life, added to while a block renders: never state that // re-renders, since the answer landing is what re-renders the page. @@ -316,7 +319,16 @@ export function PageView() { // container instead of by load order. const { app: activeApp } = useExpressionContext(); const dataSource = useAdapter(); - const page = preferLocal(pages as any[], pageName, (activeApp as any)?._packageId); + // objectstack#28645 — the page the app bundle holds, when it holds the one + // this app's package owns; else the `/meta/page` list, prefer-local, as + // before. While the bundle is on the wire the page is not yet known. + const bundledPage = useBundledItem('page', pageName, (activeApp as any)?._packageId); + const page = + bundledPage.state === 'hit' + ? bundledPage.value + : bundledPage.state === 'miss' + ? preferLocal(metadata.pages as any[], pageName, (activeApp as any)?._packageId) + : undefined; // Where "Edit in studio" goes: the page's surface in Studio's Interfaces // pillar, through the one derivation the app bar's pencil, the app @@ -343,7 +355,7 @@ export function PageView() { // race where the app's home page is the first thing rendered. Show a // loading state until the `page` type is actually resolved, then trust the // not-found. (getTypeStatus absent = hand-rolled context = always ready.) - const pageStatus = getTypeStatus?.('page'); + const pageStatus = bundledPage.state === 'pending' ? 'loading' : getTypeStatus?.('page'); if (pageStatus === 'idle' || pageStatus === 'loading') { return (
diff --git a/ui/packages/data-objectstack/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts index 89d1d079f90..dcd16d8b3cd 100644 --- a/ui/packages/data-objectstack/src/app-bundle.ts +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -60,6 +60,25 @@ export type AppBundleAnswer = export type AppBundleRequest = (appId: string, etag: string | undefined) => Promise; +/** + * The active bundle as it stands, read without waiting (objectstack#28645): + * + * - `none` — no app was entered this session; + * - `pending` — the active app's bundle has no answer in hand yet (its first + * load, or a reload after a {@link AppBundleStore.clear}); `settled` + * settles when it lands; + * - `ready` — its items as held, empty when the app has no bundle. A + * revalidation on the wire rides along as `settled`: the held items answer + * until it lands. + * + * `settled` is the load's own promise, so two peeks of one load hand out the + * same one. It never rejects. + */ +export type AppBundlePeek = + | { status: 'none' } + | { status: 'pending'; settled: Promise } + | { status: 'ready'; items: AppBundleItems; settled?: Promise }; + interface AppBundleState { appId: string; etag?: string; @@ -114,6 +133,25 @@ export class AppBundleStore { return items.get(type)?.get(name); } + /** + * The active bundle without waiting for it ({@link AppBundlePeek}). A bundle + * that is stale or past its lifetime is asked again here, as {@link read} + * asks it, but its held items keep answering until the answer lands. + */ + peek(): AppBundlePeek { + const appId = this.active; + const state = appId ? this.bundles.get(appId) : undefined; + if (!appId || !state) return { status: 'none' }; + // `ask` keeps this state (it is the one held) and sets its `inflight`. + if (!state.inflight && (state.stale || Date.now() - state.checkedAt >= this.ttlMs)) this.ask(appId); + const inflight = state.inflight; + // Never answered (a first load, or a reload after `clear()`, both start at + // 0) is `pending`. An answer that held nothing (no bundle for this app) is + // still an answer: its revalidation rides along as `settled`. + if (inflight && state.checkedAt === 0) return { status: 'pending', settled: inflight }; + return inflight ? { status: 'ready', items: state.items, settled: inflight } : { status: 'ready', items: state.items }; + } + /** Whether any bundle held this session holds `type` / `name`. */ holds(type: string, name: string): boolean { for (const state of this.bundles.values()) { diff --git a/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts new file mode 100644 index 00000000000..343b6e48488 --- /dev/null +++ b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts @@ -0,0 +1,234 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28645 — `peekAppBundle` reads the active app bundle without + * waiting, so a console reader that answers in render (a page route, a nav + * label) can answer from it and read its list only for what it does not hold. + * + * Measured through the REAL adapter and the REAL client over a transport that + * holds every request open until the test answers it. + * + * Pinned: + * - no app entered answers `none` and asks nothing; + * - the first load answers `pending`, its `settled` the one load, and `ready` + * with the items once it lands; + * - a bundle past its lifetime is asked again with `If-None-Match`, and the + * held items answer while that is on the wire; + * - an app with no bundle answers `ready` with nothing held, and keeps answering + * `ready` while it is asked again past its lifetime; + * - a cache clear answers `pending` again until the new download lands. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackAdapter } from './index'; + +const BASE = 'http://test.local'; +const APP = 'b28645_app'; +const BUNDLE_PATH = `/api/v1/meta/app/${APP}/bundle`; + +interface HeldRequest { + method: string; + path: string; + headers: Record; + respond: (status: number, body?: unknown, headers?: Record) => void; +} + +function respondWith(status: number, body: unknown, headers: Record = {}): Response { + if (status === 304) return new Response(null, { status, headers }); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +async function heldAdapter() { + const held: HeldRequest[] = []; + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? 'GET').toUpperCase(); + if (url.endsWith('/api/v1/discovery')) { + return Promise.resolve(respondWith(200, { success: true, data: { capabilities: {}, routes: {} } })); + } + const headers: Record = {}; + new Headers(init?.headers ?? {}).forEach((v, k) => { headers[k] = v; }); + return new Promise((resolve) => { + held.push({ + method, + path: decodeURIComponent(url.replace(BASE, '')), + headers, + respond: (status, body, h) => resolve(respondWith(status, body, h)), + }); + }); + }); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + return { ds, held }; +} + +async function drain() { + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); +} + +const contact = { name: 'b28645_contact', label: 'Contact', fields: { name: { type: 'text', label: 'Name' } } }; +const task = { name: 'b28645_task', label: 'Task', fields: { contact: { type: 'lookup', reference: 'b28645_contact' } } }; + +/** The bundle's answer: bare, every member the envelope its by-name read answers. */ +function bundle(label = 'Contact') { + const env = (type: string, item: { name: string; [key: string]: unknown }, extra: Record = {}) => ({ type, name: item.name, item, ...extra }); + return { + app: env('app', { name: APP, label: 'App' }), + objects: [ + env('object', { ...contact, label }, { sortability: { fields: { name: { sortable: true } } } }), + env('object', task), + ], + views: [env('view', { name: 'b28645_contact.all', object: 'b28645_contact', label: 'All' })], + pages: [env('page', { name: 'b28645_contact_page', label: 'Contact page' })], + dashboards: [env('dashboard', { name: 'b28645_home', label: 'Home' })], + }; +} + + +describe('objectstack#28645 — peekAppBundle', () => { + it('answers none before any app is entered, and asks nothing', async () => { + const { ds, held } = await heldAdapter(); + expect(ds.peekAppBundle()).toEqual({ status: 'none' }); + await drain(); + expect(held).toHaveLength(0); + }); + + it('answers pending while the first load is on the wire, then ready with what it holds', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + const first = ds.peekAppBundle(); + expect(first.status).toBe('pending'); + const second = ds.peekAppBundle(); + // Two peeks of one load hand out the load's own promise. + expect(second.status).toBe('pending'); + expect((second as { settled?: unknown }).settled).toBe((first as { settled?: unknown }).settled); + await drain(); + expect(held.map((h) => h.path)).toEqual([BUNDLE_PATH]); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await entered; + const ready = ds.peekAppBundle(); + expect(ready.status).toBe('ready'); + if (ready.status !== 'ready') return; + expect(ready.settled).toBeUndefined(); + expect((ready.items.get('page')?.get('b28645_contact_page')?.item as any).label).toBe('Contact page'); + expect(ready.items.get('view')?.has('b28645_contact.all')).toBe(true); + expect(ready.items.get('object')?.has('b28645_task')).toBe(true); + await drain(); + expect(held).toHaveLength(1); + }); + + it('past its lifetime it asks again with If-None-Match, answering from what it holds meanwhile', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await entered; + // Every held bundle asks again before its next answer (as a metadata write does). + ds.invalidateCache('b28645_contact'); + const revalidating = ds.peekAppBundle(); + expect(revalidating.status).toBe('ready'); + if (revalidating.status !== 'ready') return; + expect(revalidating.settled).toBeInstanceOf(Promise); + expect((revalidating.items.get('object')?.get('b28645_contact')?.item as any).label).toBe('Contact'); + await drain(); + expect(held.map((h) => h.path)).toEqual([BUNDLE_PATH, BUNDLE_PATH]); + expect(held[1].headers['if-none-match']).toBe('W/"b1"'); + held[1].respond(200, bundle('Person'), { ETag: 'W/"b2"' }); + await revalidating.settled; + const fresh = ds.peekAppBundle(); + expect(fresh.status === 'ready' && (fresh.items.get('object')?.get('b28645_contact')?.item as any).label).toBe('Person'); + }); + + it('an app with no bundle answers ready with nothing held', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'no' } }); + await entered; + const peek = ds.peekAppBundle(); + expect(peek.status).toBe('ready'); + expect(peek.status === 'ready' && peek.items.size).toBe(0); + }); + + it('an app with no bundle, past its lifetime, still answers ready with nothing held while it asks again', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'no' } }); + await entered; + vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000); + const revalidating = ds.peekAppBundle(); + expect(revalidating.status).toBe('ready'); + if (revalidating.status !== 'ready') return; + expect(revalidating.items.size).toBe(0); + expect(revalidating.settled).toBeInstanceOf(Promise); + await drain(); + expect(held.map((h) => h.path)).toEqual([BUNDLE_PATH, BUNDLE_PATH]); + held[1].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'no' } }); + await revalidating.settled; + expect(ds.peekAppBundle()).toEqual({ status: 'ready', items: new Map() }); + } finally { + vi.useRealTimers(); + } + }); + + it('an app with no bundle, marked stale by a whole-cache invalidation, answers ready while it asks again', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(404, { success: false, error: { code: 'NOT_FOUND', message: 'no' } }); + await entered; + ds.invalidateCache(); + const revalidating = ds.peekAppBundle(); + expect(revalidating.status).toBe('ready'); + expect(revalidating.status === 'ready' && revalidating.settled).toBeInstanceOf(Promise); + await drain(); + expect(held).toHaveLength(2); + }); + + it('an app whose bundle answered with no items, past its lifetime, answers ready while it asks again', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, {}, { ETag: 'W/"e1"' }); + await entered; + vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000); + const revalidating = ds.peekAppBundle(); + expect(revalidating.status).toBe('ready'); + expect(revalidating.status === 'ready' && revalidating.settled).toBeInstanceOf(Promise); + await drain(); + expect(held).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it('after a cache clear it answers pending until the new download lands', async () => { + const { ds, held } = await heldAdapter(); + const entered = ds.loadAppBundle(APP); + await drain(); + held[0].respond(200, bundle(), { ETag: 'W/"b1"' }); + await entered; + ds.clearCache(); + const cleared = ds.peekAppBundle(); + expect(cleared.status).toBe('pending'); + await drain(); + expect(held).toHaveLength(2); + expect(held[1].headers['if-none-match']).toBeUndefined(); + held[1].respond(200, bundle(), { ETag: 'W/"b2"' }); + if (cleared.status === 'pending') await cleared.settled; + expect(ds.peekAppBundle().status).toBe('ready'); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 883b3007fde..0eb325f3713 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -91,7 +91,7 @@ import { type DatasetDrillRange, } from '@object-ui/core'; import { MetadataCache } from './cache/MetadataCache'; -import { AppBundleStore, indexAppBundle, type AppBundleAnswer } from './app-bundle'; +import { AppBundleStore, indexAppBundle, type AppBundleAnswer, type AppBundlePeek } from './app-bundle'; import { MetadataClient } from './metadata-client'; import { formatMetadataError } from './metadata-error'; import { @@ -4584,6 +4584,19 @@ export class ObjectStackAdapter implements DataSource { return this.appBundles.enter(appId); } + /** + * The active app bundle as it stands, without waiting for it + * (objectstack#28645): `none` before any app is entered, `pending` while its + * first answer is on the wire, else `ready` with the items it holds (empty + * for an app with no bundle). A stale bundle is asked again, and answers + * from what it holds until that lands. A metadata reader that must answer + * in render (a list a route looks a name up in) reads the bundle here, and + * reads its own route for anything the bundle does not hold. + */ + peekAppBundle(): AppBundlePeek { + return this.appBundles.peek(); + } + /** The one round trip behind {@link loadAppBundle}. */ private async requestAppBundle(appId: string, etag: string | undefined): Promise { await this.connect(); @@ -7752,6 +7765,8 @@ export { // Export cache types export type { MetadataCacheStats } from './cache/MetadataCache'; +// objectstack#28645 — what `ObjectStackAdapter.peekAppBundle` answers. +export type { AppBundlePeek, AppBundleItems, AppBundleEntry } from './app-bundle'; // User-scoped persistence adapter (favorites / recent items / …) export {