From 2584266c07d8b445dd7bf8419dab84cccf5027bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:27:34 +0000 Subject: [PATCH 01/14] feat(console): answer an app page's views, page and dashboard reads from the app bundle first The provider no longer reads /meta/view at mount. A scope composes its objects with the app bundle's views when the bundle holds every object in it, and the page route, the app bar title and the nav and recent-item labels read their page or dashboard from the bundle, reading the list only for an item the bundle does not hold. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../app-shell/src/console/AppContent.tsx | 19 +- .../app-shell/src/hooks/useNavTargetLabel.ts | 9 +- .../app-shell/src/hooks/useRecentItemLabel.ts | 7 +- .../app-shell/src/hooks/useServedViewItems.ts | 32 ++- .../app-shell/src/layout/AppHeader.tsx | 27 +- .../src/providers/MetadataProvider.tsx | 260 +++++++++++++++++- ui/packages/app-shell/src/views/PageView.tsx | 19 +- .../data-objectstack/src/app-bundle.ts | 35 +++ ui/packages/data-objectstack/src/index.ts | 17 +- 9 files changed, 393 insertions(+), 32 deletions(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 91d2e974444..e1429ec4398 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -19,7 +19,7 @@ import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals. import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n'; import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useMetadata } from '../providers/MetadataProvider.js'; +import { useBundledReads, useMetadata } from '../providers/MetadataProvider.js'; import { useAdapter } from '../providers/AdapterProvider.js'; import { usePreviewDrafts } from '../preview/PreviewModeContext.js'; import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState.js'; @@ -318,6 +318,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 @@ -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/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index 3ab84540819..cc9b41b3ab2 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -69,7 +69,7 @@ import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui 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, useMetadata } from '../providers/MetadataProvider.js'; /** * A metadata label (plain string or inline locale map) as display text, or @@ -139,6 +139,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 +173,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/useRecentItemLabel.ts b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts index fe4fc85f838..6c1a3f457f8 100644 --- a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -60,7 +60,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, useMetadata } from '../providers/MetadataProvider.js'; import { findByName, metadataText, useNavTargetLabel, type LabelledRecord } from './useNavTargetLabel.js'; /** Answers the display text of a recent entry, in the current language. */ @@ -80,6 +80,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 +103,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 +116,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..b1908db6a3f 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -87,7 +87,7 @@ import { useAdapter } from '../providers/AdapterProvider.js'; 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, useMetadata } from '../providers/MetadataProvider.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 +476,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 +684,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 +709,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..01fd17a65aa 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -1,4 +1,6 @@ import { + createContext, + useContext, useState, useEffect, useCallback, @@ -8,7 +10,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'; @@ -130,7 +139,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 +718,105 @@ export function attachInlineSubforms(objects: any[]): any[] { */ const COMPOSED_OBJECTS = new WeakMap(); +// --------------------------------------------------------------------------- +// objectstack#28645 — bundle-first reads, answered in render +// --------------------------------------------------------------------------- + +/** + * 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' }; + +const BUNDLE_PENDING: BundledRead = Object.freeze({ state: 'pending' as const }); +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; +} + +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, + ); +} + +/** + * 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[] = []; + function composeObjects(objs: unknown[], views: unknown[]): unknown[] { const cached = COMPOSED_OBJECTS.get(objs); if (cached && cached.views === views) return cached.composed; @@ -854,6 +969,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 +1078,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 +1172,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 +1612,21 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: [ensureType, getEntry], ); + /** The views to compose `objects` with (objectstack#28645), and whether they are in hand. */ + const viewsForObjects = useCallback( + (objects: readonly unknown[]): { views: unknown[]; settled: boolean } => { + const names: string[] = []; + for (const it of objects) if (isNamedItem(it)) names.push(it.name); + const bundled = readBundledViews(names); + if (bundled.state === 'hit') return { views: bundled.value, settled: true }; + if (bundled.state === 'pending') return { views: NO_VIEWS, settled: false }; + const views = readType('view'); + const status = getEntry('view').status; + return { views, settled: status === 'ready' || status === 'error' }; + }, + [getEntry, readBundledViews, 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 @@ -1427,11 +1640,13 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: 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 +1710,23 @@ 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: the app bundle's when it holds + // every object in the scope, else the `/meta/view` list. 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`). + const scopeViews = viewsForObjects(entry.items); 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 +1759,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/views/PageView.tsx b/ui/packages/app-shell/src/views/PageView.tsx index 2e3b1ed4e7c..e4d554a6928 100644 --- a/ui/packages/app-shell/src/views/PageView.tsx +++ b/ui/packages/app-shell/src/views/PageView.tsx @@ -33,7 +33,7 @@ import { Button, DataErrorState, Empty, EmptyTitle, EmptyDescription, Spinner } 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, useMetadata } from '../providers/MetadataProvider.js'; import { PageRecordScope, useExpressionContext } from '../providers/ExpressionProvider.js'; import { preferLocal } from '../utils/preferLocal.js'; import { appStudioRoutePath } from '../utils/appRoute.js'; @@ -306,7 +306,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 +318,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 +354,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..4fe5cac1974 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,22 @@ 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; + if (inflight && state.items.size === 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/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 { From 9bf10a3c14ab8786c7917ed973942e70fc518582 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:35:33 +0000 Subject: [PATCH 02/14] test(console): pin the bundle-first reads, and read the provider view list on demand in the list-sharing pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../28645-app-page-reads-from-bundle.md | 18 ++ ...taProvider.bundleFirstReads-28645.test.tsx | 249 ++++++++++++++++++ ...dataProvider.namesOnlyScope-12980.test.tsx | 10 +- ...etadataProvider.objectScope-12811.test.tsx | 14 +- ...ataProvider.oneMetadataRead-13044.test.tsx | 29 +- ...Provider.switchDropsAdapter-13044.test.tsx | 10 +- .../src/appBundlePeek-28645.test.ts | 176 +++++++++++++ 7 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 .changeset/28645-app-page-reads-from-bundle.md create mode 100644 ui/packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx create mode 100644 ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts 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..e96e7e1d97a --- /dev/null +++ b/.changeset/28645-app-page-reads-from-bundle.md @@ -0,0 +1,18 @@ +--- +'@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.** A set of objects a route composes is composed with the bundle's views when the bundle holds every one of those objects. The `/meta/view` list is read beside the whole object registry, or for a set holding an object outside the bundle, and such a set waits for it before it answers. +- **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/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..956b663a529 --- /dev/null +++ b/ui/packages/app-shell/src/providers/__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx @@ -0,0 +1,249 @@ +// 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, + useBundledItem, + useBundledReads, + useMetadata, + type BundledRead, + type BundledReads, +} from '../MetadataProvider'; + +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) { + 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 : []; + return Promise.resolve({ type, items: structuredClone(all) }); + }, + 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 } = {}) { + 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( + + + + , + ); + return { calls, 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('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'); + }); +}); 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/data-objectstack/src/appBundlePeek-28645.test.ts b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts new file mode 100644 index 00000000000..2995593ea96 --- /dev/null +++ b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts @@ -0,0 +1,176 @@ +/** + * 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; + * - 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('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'); + }); +}); From 1184f7a4479b0ab0349c641a48a8abbaefc1ee44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:52:23 +0000 Subject: [PATCH 03/14] refactor(console): bundle-first reads live beside the provider, so a provider stand-in leaves readers on their list Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../app-shell/src/console/AppContent.tsx | 3 +- .../ConnectedShell.sessionGate.test.tsx | 7 +- .../app-shell/src/hooks/useNavTargetLabel.ts | 3 +- .../app-shell/src/hooks/useRecentItemLabel.ts | 3 +- .../app-shell/src/layout/AppHeader.tsx | 3 +- .../src/providers/MetadataProvider.tsx | 74 ++--------------- ...taProvider.bundleFirstReads-28645.test.tsx | 47 +++++++++-- .../app-shell/src/providers/bundledReads.ts | 81 +++++++++++++++++++ ui/packages/app-shell/src/views/PageView.tsx | 3 +- 9 files changed, 142 insertions(+), 82 deletions(-) create mode 100644 ui/packages/app-shell/src/providers/bundledReads.ts diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index e1429ec4398..e3d383d5930 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -19,7 +19,8 @@ import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals. import { useObjectTranslation, useObjectLabel, recordSaveMessage as buildRecordSaveMessage } from '@object-ui/i18n'; import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useBundledReads, useMetadata } from '../providers/MetadataProvider.js'; +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'; 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/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index cc9b41b3ab2..761158e819f 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -69,7 +69,8 @@ import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui import { resolveViewId } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout'; -import { bundledFirst, useBundledReads, useMetadata } from '../providers/MetadataProvider.js'; +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 diff --git a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts index 6c1a3f457f8..46fde56ea21 100644 --- a/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts +++ b/ui/packages/app-shell/src/hooks/useRecentItemLabel.ts @@ -60,7 +60,8 @@ import { useMemo } from 'react'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { RecentItem } from '../context/RecentItemsProvider.js'; -import { bundledFirst, useBundledReads, useMetadata } from '../providers/MetadataProvider.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. */ diff --git a/ui/packages/app-shell/src/layout/AppHeader.tsx b/ui/packages/app-shell/src/layout/AppHeader.tsx index b1908db6a3f..6aaf1a3e781 100644 --- a/ui/packages/app-shell/src/layout/AppHeader.tsx +++ b/ui/packages/app-shell/src/layout/AppHeader.tsx @@ -87,7 +87,8 @@ import { useAdapter } from '../providers/AdapterProvider.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials } from '@object-ui/auth'; -import { useBundledReads, useMetadata } from '../providers/MetadataProvider.js'; +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'; diff --git a/ui/packages/app-shell/src/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index 01fd17a65aa..1c0642fd73c 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -1,6 +1,4 @@ import { - createContext, - useContext, useState, useEffect, useCallback, @@ -25,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 @@ -722,71 +727,6 @@ const COMPOSED_OBJECTS = new WeakMap = - | { readonly state: 'pending' } - | { readonly state: 'hit'; readonly value: T } - | { readonly state: 'miss' }; - -const BUNDLE_PENDING: BundledRead = Object.freeze({ state: 'pending' as const }); -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; -} - -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, - ); -} - /** * 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 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 index 956b663a529..615653ea009 100644 --- 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 @@ -29,14 +29,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { act, cleanup, render, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; -import { - MetadataProvider, - useBundledItem, - useBundledReads, - useMetadata, - type BundledRead, - type BundledReads, -} from '../MetadataProvider'; +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 }; @@ -246,4 +241,40 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { 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/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 e4d554a6928..1285c53ec7e 100644 --- a/ui/packages/app-shell/src/views/PageView.tsx +++ b/ui/packages/app-shell/src/views/PageView.tsx @@ -33,7 +33,8 @@ import { Button, DataErrorState, Empty, EmptyTitle, EmptyDescription, Spinner } import { FileText, Pencil, RotateCw } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; -import { useBundledItem, useMetadata } from '../providers/MetadataProvider.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'; From c6f9db6cd4cc6cf63f66e6a1611f0661532c3637 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:00:13 +0000 Subject: [PATCH 04/14] test(console): type the bundle double for the type-check Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../__tests__/MetadataProvider.bundleFirstReads-28645.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 615653ea009..bcf6082033a 100644 --- 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 @@ -136,7 +136,8 @@ function mount(opts: { bundle?: Bundle | null; enterInEffect?: Bundle } = {}) { , ); - return { calls, bundle, ctx: () => ctx as unknown as Ctx, reads: () => reads as BundledReads, page: () => page as BundledRead }; + // `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('/')); From 86ff6613af589f7385780626ac9fef66e2b29f3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:45:17 +0000 Subject: [PATCH 05/14] fix(console): a package-scoped app composes its views from the bundle when every asked object is bundled Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../src/providers/MetadataProvider.tsx | 76 ++++++++++++++++--- ...taProvider.bundleFirstReads-28645.test.tsx | 48 ++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/ui/packages/app-shell/src/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index 1c0642fd73c..dd6556811d6 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -757,6 +757,44 @@ function bundledViews(items: AppBundleItems): BundledRead { /** 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): a `/meta/view` list already in hand still composes + * them. 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; @@ -1552,19 +1590,32 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: [ensureType, getEntry], ); - /** The views to compose `objects` with (objectstack#28645), and whether they are in hand. */ + /** + * 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). When the app bundle holds + * each of them, the bundle's views answer, since it carries every view of + * every object it holds. A package object the bundle does not hold, and that + * no reader asked by name, is composed from a `/meta/view` list already in + * hand, and asks for none: a reader of its views names it. Otherwise the + * scope reads the `/meta/view` list, as before. + */ const viewsForObjects = useCallback( - (objects: readonly unknown[]): { views: unknown[]; settled: boolean } => { - const names: string[] = []; - for (const it of objects) if (isNamedItem(it)) names.push(it.name); - const bundled = readBundledViews(names); - if (bundled.state === 'hit') return { views: bundled.value, settled: true }; + (asked: readonly string[]): { views: unknown[]; settled: boolean } => { + const bundled = readBundledViews(asked); if (bundled.state === 'pending') return { views: NO_VIEWS, settled: false }; + const listed = getEntry('view'); + if (bundled.state === 'hit') { + if (listed.status !== 'ready') return { views: bundled.value, settled: true }; + const peek = peekBundle(); + const bundledObjects = peek?.status === 'ready' ? peek.items.get('object') : undefined; + return { views: bundledWithListed(bundled.value, bundledObjects, listed.items), settled: true }; + } const views = readType('view'); const status = getEntry('view').status; return { views, settled: status === 'ready' || status === 'error' }; }, - [getEntry, readBundledViews, readType], + [getEntry, peekBundle, readBundledViews, readType], ); // objectui#12811 — see `MetadataContextValue.readObjectScope`. Reading it @@ -1651,11 +1702,12 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: // readers read them (`appObjectScope.ts`), other readers never see them. const atRest = entry.answered && namesSettled; // objectstack#28645 — the scope's views: the app bundle's when it holds - // every object in the scope, else the `/meta/view` list. 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`). - const scopeViews = viewsForObjects(entry.items); + // every name asked (a names-only scope: every object in it), else the + // `/meta/view` list (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`). + const scopeViews = viewsForObjects(namesOnly ? itemNames(entry.items) : (scope.names ?? NO_NAMES)); return { objects: composeObjects(entry.items, scopeViews.views) as any[], served: entry.items, 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 index bcf6082033a..a140a7c4ca1 100644 --- 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 @@ -189,6 +189,54 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { 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('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')); From 1764fd241b3994c7bd0179971ef9687aa1e8393a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:04:02 +0000 Subject: [PATCH 06/14] fix(console): the root scope prefetch enters the app bundle as it starts the scope Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../__tests__/appObjectScope-12811.test.tsx | 26 +++++++++++++++++-- .../app-shell/src/console/appObjectScope.ts | 24 ++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) 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..a015b940d5b 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,6 +20,7 @@ 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'; @@ -96,12 +97,33 @@ 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}`); + }); + 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..4fd6a0aa423 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'; @@ -440,14 +441,25 @@ export function readAppObjectScope( /** * 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 + * (`loadAppBundle`, which `AppContent` joins), so the scope it starts composes + * its views from the bundle instead of reading the `/meta/view` list before + * the app was entered. */ export function useAppObjectScopePrefetch(): void { const { apps, readObjectScope } = useMetadata(); + const dataSource = useAdapter() as { loadAppBundle?: (appName: string) => unknown } | null | undefined; 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 (!bundleAppName || typeof load !== 'function') return; + void load.call(dataSource, bundleAppName); + }, [dataSource, bundleAppName]); + if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search); } From 356259b6b12abd5626269e609148572e8fe41823 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:18:13 +0000 Subject: [PATCH 07/14] fix(console): enter the app bundle once at the root, keep a settled list, and let presence skip views Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../app-shell/src/console/AppContent.tsx | 7 +- .../__tests__/appObjectScope-12811.test.tsx | 11 ++- .../app-shell/src/console/appObjectScope.ts | 37 ++++++-- .../app-shell/src/hooks/useObjectPresence.ts | 8 +- .../src/providers/MetadataProvider.tsx | 84 +++++++------------ ...taProvider.bundleFirstReads-28645.test.tsx | 29 +++++++ 6 files changed, 112 insertions(+), 64 deletions(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index e3d383d5930..6cb2274d3e1 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -68,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'; @@ -399,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 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 a015b940d5b..746bee8151d 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 @@ -22,7 +22,7 @@ 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 = [ @@ -115,6 +115,15 @@ describe('the console root starts the app object scope (objectui#12811)', () => ); 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'); + 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 () => { diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index 4fd6a0aa423..7233e7bae7b 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -438,28 +438,53 @@ 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, per adapter, while it stays entered. */ +const ROOT_ENTERED_APP = new WeakMap(); + +/** + * 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 (ROOT_ENTERED_APP.get(host) === 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 renders nothing; `AppContent` reads * the same scope entry when it renders. * - * objectstack#28645 — it enters the app's metadata bundle at the same moment - * (`loadAppBundle`, which `AppContent` joins), so the scope it starts composes - * its views from the bundle instead of reading the `/meta/view` list before - * the app was entered. + * 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 { loadAppBundle?: (appName: string) => unknown } | null | undefined; + const dataSource = useAdapter() as BundleHost | null; const { pathname, search } = useLocation(); const segments = pathname.split('/').filter(Boolean); 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 (!bundleAppName || typeof load !== 'function') return; + if (!dataSource || !bundleAppName || typeof load !== 'function') return; void load.call(dataSource, bundleAppName); + ROOT_ENTERED_APP.set(dataSource, bundleAppName); + return () => { + if (ROOT_ENTERED_APP.get(dataSource) === bundleAppName) ROOT_ENTERED_APP.delete(dataSource); + }; }, [dataSource, bundleAppName]); if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search); } 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/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index dd6556811d6..13ebfbbc2ec 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -764,37 +764,6 @@ function itemNames(items: readonly unknown[]): string[] { 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): a `/meta/view` list already in hand still composes - * them. 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; @@ -1593,29 +1562,28 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: /** * 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). When the app bundle holds - * each of them, the bundle's views answer, since it carries every view of - * every object it holds. A package object the bundle does not hold, and that - * no reader asked by name, is composed from a `/meta/view` list already in - * hand, and asks for none: a reader of its views names it. Otherwise the - * scope reads the `/meta/view` list, as before. + * for (a names-only scope: every object in it). + * + * - A `/meta/view` list already in hand answers, as it always has: a scope + * never recomposes, or unsettles, because an app was entered after it. + * - Otherwise, when the app bundle holds each asked name, the bundle's views + * answer: it carries every view of every object it holds. A package + * object the bundle does not hold, and that no reader asked by name, is + * composed without views; a reader of its views names it. + * - Otherwise the scope reads the `/meta/view` list, as before. */ const viewsForObjects = useCallback( (asked: readonly string[]): { views: unknown[]; settled: boolean } => { + const listed = getEntry('view'); + if (listed.status === 'ready') return { views: listed.items, settled: true }; const bundled = readBundledViews(asked); + if (bundled.state === 'hit') return { views: bundled.value, settled: true }; if (bundled.state === 'pending') return { views: NO_VIEWS, settled: false }; - const listed = getEntry('view'); - if (bundled.state === 'hit') { - if (listed.status !== 'ready') return { views: bundled.value, settled: true }; - const peek = peekBundle(); - const bundledObjects = peek?.status === 'ready' ? peek.items.get('object') : undefined; - return { views: bundledWithListed(bundled.value, bundledObjects, listed.items), settled: true }; - } const views = readType('view'); const status = getEntry('view').status; return { views, settled: status === 'ready' || status === 'error' }; }, - [getEntry, peekBundle, readBundledViews, readType], + [getEntry, readBundledViews, readType], ); // objectui#12811 — see `MetadataContextValue.readObjectScope`. Reading it @@ -1625,7 +1593,7 @@ 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) { @@ -1701,12 +1669,24 @@ 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: the app bundle's when it holds - // every name asked (a names-only scope: every object in it), else the - // `/meta/view` list (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`). + // 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, scopeViews.views) as any[], 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 index a140a7c4ca1..fb457c5fa63 100644 --- 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 @@ -237,6 +237,35 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { expect(lists(m.calls)).toEqual(['app', 'view', `object?package=${PKG}`]); }); + it('a scope settled on the view list stays settled, and keeps its objects, when an app is entered after it', 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(); }); + expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).objects).toBe(before.objects); + }); + + 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')); From 2cd9a13e26cc0d9b58c1e171f4aedc2284503d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:26:53 +0000 Subject: [PATCH 08/14] fix(console): AppContent recognises the root's bundle entry through the adapter wrapper Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../__tests__/appObjectScope-12811.test.tsx | 3 +++ .../app-shell/src/console/appObjectScope.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) 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 746bee8151d..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 @@ -118,6 +118,9 @@ describe('the console root starts the app object scope (objectui#12811)', () => // `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']); diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index 7233e7bae7b..89d6a95b08c 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -441,8 +441,13 @@ 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, per adapter, while it stays entered. */ -const ROOT_ENTERED_APP = new WeakMap(); +/** + * objectstack#28645 — the app the console root entered, while it stays + * entered. 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. + */ +let rootEnteredApp: string | undefined; /** * Enter `appName`'s metadata bundle (objectstack#27627), unless the console @@ -454,7 +459,7 @@ export function enterAppBundle(dataSource: unknown, appName: string | undefined) const host = dataSource as BundleHost | null | undefined; const load = host?.loadAppBundle; if (!host || !appName || typeof load !== 'function') return; - if (ROOT_ENTERED_APP.get(host) === appName) return; + if (rootEnteredApp === appName) return; void load.call(host, appName); } @@ -481,9 +486,9 @@ export function useAppObjectScopePrefetch(): void { const load = dataSource?.loadAppBundle; if (!dataSource || !bundleAppName || typeof load !== 'function') return; void load.call(dataSource, bundleAppName); - ROOT_ENTERED_APP.set(dataSource, bundleAppName); + rootEnteredApp = bundleAppName; return () => { - if (ROOT_ENTERED_APP.get(dataSource) === bundleAppName) ROOT_ENTERED_APP.delete(dataSource); + if (rootEnteredApp === bundleAppName) rootEnteredApp = undefined; }; }, [dataSource, bundleAppName]); if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search); From c57acb39db49bd1a74a903ecdb0cb07d39debfdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:33:43 +0000 Subject: [PATCH 09/14] fix(console): a bundled object composes with the bundle's views while the list loads for the rest Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../src/providers/MetadataProvider.tsx | 68 ++++++++++++++++--- ...taProvider.bundleFirstReads-28645.test.tsx | 23 ++++++- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/ui/packages/app-shell/src/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index 13ebfbbc2ec..4b262389cb6 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -764,6 +764,38 @@ function itemNames(items: readonly unknown[]): string[] { 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; @@ -1564,26 +1596,40 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: * whether they are in hand. `asked` are the names the scope's reader asked * for (a names-only scope: every object in it). * - * - A `/meta/view` list already in hand answers, as it always has: a scope - * never recomposes, or unsettles, because an app was entered after it. - * - Otherwise, when the app bundle holds each asked name, the bundle's views - * answer: it carries every view of every object it holds. A package - * object the bundle does not hold, and that no reader asked by name, is - * composed without views; a reader of its views names it. - * - Otherwise the scope reads the `/meta/view` list, as before. + * - 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: a list already in hand answers, so a scope + * never unsettles because an app was entered after it; otherwise the + * scope waits for the bundle. + * - 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 }; - const bundled = readBundledViews(asked); - if (bundled.state === 'hit') return { views: bundled.value, settled: true }; - if (bundled.state === 'pending') return { views: NO_VIEWS, settled: false }; + if (peek?.status === 'pending') return { views: NO_VIEWS, settled: false }; const views = readType('view'); const status = getEntry('view').status; return { views, settled: status === 'ready' || status === 'error' }; }, - [getEntry, readBundledViews, readType], + [getEntry, peekBundle, readType], ); // objectui#12811 — see `MetadataContextValue.readObjectScope`. Reading it 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 index fb457c5fa63..e969e392216 100644 --- 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 @@ -237,7 +237,7 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { expect(lists(m.calls)).toEqual(['app', 'view', `object?package=${PKG}`]); }); - it('a scope settled on the view list stays settled, and keeps its objects, when an app is entered after it', async () => { + 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(() => { @@ -249,7 +249,26 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { expect(during.settled).toBe(true); expect(during.objects).toBe(before.objects); await act(async () => { await m.bundle.land(); }); - expect(m.ctx().readObjectScope!({ packageId: PKG, names: ['b28645_plan'] }).objects).toBe(before.objects); + // 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 () => { + const m = mount(); + 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(); + 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 () => { From f52d56ed37b3e95265b5e607230470d76b2745c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:41:03 +0000 Subject: [PATCH 10/14] fix(console): a scope read before the app is entered in the same commit waits for its bundle Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../app-shell/src/providers/MetadataProvider.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/packages/app-shell/src/providers/MetadataProvider.tsx b/ui/packages/app-shell/src/providers/MetadataProvider.tsx index 4b262389cb6..e667647fb38 100644 --- a/ui/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/ui/packages/app-shell/src/providers/MetadataProvider.tsx @@ -1602,9 +1602,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: * 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: a list already in hand answers, so a scope - * never unsettles because an app was entered after it; otherwise the - * scope waits for the bundle. + * - 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( @@ -1624,7 +1624,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }: return { views: bundledWithListed(fromBundle, bundledObjects, list), settled: status === 'ready' || status === 'error' }; } if (listed.status === 'ready') return { views: listed.items, settled: true }; - if (peek?.status === 'pending') return { views: NO_VIEWS, settled: false }; + // `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' }; From 80859f6989964c65cf69b57efb7f60115914ea1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:52:15 +0000 Subject: [PATCH 11/14] test(console): hold the view list so the bundled-object pin sees it loading Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- ...etadataProvider.bundleFirstReads-28645.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 index e969e392216..760267c27a0 100644 --- 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 @@ -86,7 +86,7 @@ function makeBundle(): Bundle { return bundle; } -function makeAdapter(calls: string[], bundle: Bundle | null) { +function makeAdapter(calls: string[], bundle: Bundle | null, viewsHeld?: Promise) { return { clearCache: () => {}, invalidateCache: () => {}, @@ -101,7 +101,8 @@ function makeAdapter(calls: string[], bundle: Bundle | null) { 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 : []; - return Promise.resolve({ type, items: structuredClone(all) }); + 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}`); @@ -112,7 +113,7 @@ function makeAdapter(calls: string[], bundle: Bundle | null) { } as unknown as Parameters[0]['adapter']; } -function mount(opts: { bundle?: Bundle | null; enterInEffect?: Bundle } = {}) { +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; @@ -131,7 +132,7 @@ function mount(opts: { bundle?: Bundle | null; enterInEffect?: Bundle } = {}) { return null; } render( - + , @@ -259,7 +260,8 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { }); it('a bundled object keeps the bundle\'s views while a scope that asks an outside object reads the list', async () => { - const m = mount(); + 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']; @@ -268,6 +270,8 @@ describe('MetadataProvider — bundle-first reads (objectstack#28645)', () => { 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)); }); From 23fefe555070290cd136a161bb4e5b58ac2738da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:52:40 +0000 Subject: [PATCH 12/14] docs(changeset): the root bundle entry, per-object views and the presence read Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .changeset/28645-app-page-reads-from-bundle.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/28645-app-page-reads-from-bundle.md b/.changeset/28645-app-page-reads-from-bundle.md index e96e7e1d97a..597f19b1e11 100644 --- a/.changeset/28645-app-page-reads-from-bundle.md +++ b/.changeset/28645-app-page-reads-from-bundle.md @@ -10,7 +10,9 @@ 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.** A set of objects a route composes is composed with the bundle's views when the bundle holds every one of those objects. The `/meta/view` list is read beside the whole object registry, or for a set holding an object outside the bundle, and such a set waits for it before it answers. +- **`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. From 75df927491151a711e3f15129f2daec98025f778 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:05:04 +0000 Subject: [PATCH 13/14] fix(console): keep the root bundle entry in a module set the hooks lint accepts Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- ui/packages/app-shell/src/console/appObjectScope.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/console/appObjectScope.ts b/ui/packages/app-shell/src/console/appObjectScope.ts index 89d6a95b08c..1a75ed3b061 100644 --- a/ui/packages/app-shell/src/console/appObjectScope.ts +++ b/ui/packages/app-shell/src/console/appObjectScope.ts @@ -443,11 +443,11 @@ type BundleHost = { loadAppBundle?: (appName: string) => unknown }; /** * objectstack#28645 — the app the console root entered, while it stays - * entered. Kept by name, not by adapter: below the root, `AppContent` reads a + * 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. */ -let rootEnteredApp: string | undefined; +const rootEnteredApps = new Set(); /** * Enter `appName`'s metadata bundle (objectstack#27627), unless the console @@ -459,7 +459,7 @@ export function enterAppBundle(dataSource: unknown, appName: string | undefined) const host = dataSource as BundleHost | null | undefined; const load = host?.loadAppBundle; if (!host || !appName || typeof load !== 'function') return; - if (rootEnteredApp === appName) return; + if (rootEnteredApps.has(appName)) return; void load.call(host, appName); } @@ -486,9 +486,9 @@ export function useAppObjectScopePrefetch(): void { const load = dataSource?.loadAppBundle; if (!dataSource || !bundleAppName || typeof load !== 'function') return; void load.call(dataSource, bundleAppName); - rootEnteredApp = bundleAppName; + rootEnteredApps.add(bundleAppName); return () => { - if (rootEnteredApp === bundleAppName) rootEnteredApp = undefined; + rootEnteredApps.delete(bundleAppName); }; }, [dataSource, bundleAppName]); if (app && readObjectScope) readAppObjectScope(readObjectScope, app, segments.slice(2).join('/'), search); From 935a89e3ee7464b4a950d1cb6e3113fabe9d4660 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:32:26 +0000 Subject: [PATCH 14/14] fix(data-objectstack): peekAppBundle answers ready for a no-bundle app while it revalidates Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUjXYpqdaAkEWBHbqG --- .../data-objectstack/src/app-bundle.ts | 5 +- .../src/appBundlePeek-28645.test.ts | 60 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ui/packages/data-objectstack/src/app-bundle.ts b/ui/packages/data-objectstack/src/app-bundle.ts index 4fe5cac1974..dcd16d8b3cd 100644 --- a/ui/packages/data-objectstack/src/app-bundle.ts +++ b/ui/packages/data-objectstack/src/app-bundle.ts @@ -145,7 +145,10 @@ export class AppBundleStore { // `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; - if (inflight && state.items.size === 0) return { status: 'pending', settled: 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 }; } diff --git a/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts index 2995593ea96..343b6e48488 100644 --- a/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts +++ b/ui/packages/data-objectstack/src/appBundlePeek-28645.test.ts @@ -20,7 +20,8 @@ * 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; + * - 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'; @@ -157,6 +158,63 @@ describe('objectstack#28645 — peekAppBundle', () => { 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);