From 78a7a9210bd7016f99b80d93f91c2b8a4ef0f407 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:59:18 +0000 Subject: [PATCH 1/4] fix(console): after an install into this environment, wait until its app is served before refreshing the app list The runtime serves the pre-install kernel while it rebuilds after a cloud install. The package page refreshed and persisted the app list the moment the install answered, so the cached list was the pre-install one and the installed app never appeared in the installing tab. The page now reads GET /meta/app past the metadata cache on a bound until an app wears the package's manifest id as _packageId, and only then drops the session seed, refreshes the cache and pulses the bus. It shows the deploying state until then, offers to open the app once served, and on expiry says the app has not appeared and offers to check again. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .changeset/12087-install-wait-served.md | 16 + .../marketplace/MarketplacePackagePage.tsx | 193 +++++++++--- ...cePackagePage.waitServedApp-12087.test.tsx | 289 ++++++++++++++++++ .../__tests__/waitForServedApp-12087.test.ts | 121 ++++++++ .../console/marketplace/waitForServedApp.ts | 112 +++++++ packages/i18n/src/locales/ar.ts | 5 + packages/i18n/src/locales/de.ts | 5 + packages/i18n/src/locales/en.ts | 7 + packages/i18n/src/locales/es.ts | 5 + packages/i18n/src/locales/fr.ts | 5 + packages/i18n/src/locales/ja.ts | 5 + packages/i18n/src/locales/ko.ts | 5 + packages/i18n/src/locales/pt.ts | 5 + packages/i18n/src/locales/ru.ts | 5 + packages/i18n/src/locales/zh.ts | 5 + 15 files changed, 746 insertions(+), 37 deletions(-) create mode 100644 .changeset/12087-install-wait-served.md create mode 100644 packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx create mode 100644 packages/app-shell/src/console/marketplace/__tests__/waitForServedApp-12087.test.ts create mode 100644 packages/app-shell/src/console/marketplace/waitForServedApp.ts diff --git a/.changeset/12087-install-wait-served.md b/.changeset/12087-install-wait-served.md new file mode 100644 index 0000000000..a2a27afcce --- /dev/null +++ b/.changeset/12087-install-wait-served.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +After a marketplace install into the environment the console is rendering, the package page waits until that environment serves the installed package's app, and only then refreshes and persists the app list (objectui#12087). + +A cloud install answers once the control plane has written the installation. The environment's runtime then rebuilds its kernel and keeps serving the kernel from before the install until the rebuild lands (about 60 s, measured on staging). The page used to refresh the metadata cache the moment the install answered. That refresh read the pre-install app list, and the cache kept it, in memory and in the tab's session seed. The page said "Installed successfully", and the app never appeared in that tab. + +- **Wait.** The page reads `GET /meta/app` past the cache, at once and then every 5 s for up to 5 minutes, until an app's `_packageId` is the package's manifest id. Only then does it drop the session seed, refresh the cache and send the metadata-changed signal. No app list read during the wait is persisted. The 5 minutes bound the wait; they do not estimate the rebuild. +- **Say what is happening.** Until the app is served, the dialog, and the page once the dialog is closed, show "Deploying the app…" (「正在部署应用…」). When it is served, they show that the app is ready and offer "Open {name}" (「打开 {name}」). +- **Say what is not known.** When the wait expires, the page says the install was recorded but the app has not appeared yet, names the possible reasons, and offers "Check again". It does not claim success, and it refreshes nothing. +- The install-time suggested audience bindings mount once the app is served, so their one read reaches the rebuilt runtime. +- An install into another environment shows the same success message as before. It no longer refreshes this console's own metadata, which that install did not change. + +Five language-pack keys are added under `marketplace.install` (`deploying`, `deployed`, `openApp`, `deployTimeout`, `checkAgain`), in all ten packs. No export, prop or type changes on the package entries. diff --git a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index 91ed0c3069..f22afb2594 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -35,8 +35,9 @@ import { } from '@object-ui/components'; import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2, MoreHorizontal, CheckCircle2, ArrowUpCircle, Database, Loader2 } from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { useDisplayLocale } from '@object-ui/i18n'; +import { useAdapter } from '@object-ui/react'; import { PackageIcon } from './PackageIcon.js'; import { MarkdownText } from './MarkdownText.js'; import { PluginDisclosure } from './PluginDisclosure.js'; @@ -65,13 +66,40 @@ import { type LocalInstallNotLoaded, type CloudInstallationInfo, } from './marketplaceApi.js'; -import { getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; +import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; -import { useMetadata } from '../../providers/MetadataProvider.js'; +import { extractItems, useMetadata } from '../../providers/MetadataProvider.js'; +import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; +import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel.js'; import type { SuggestedBinding } from '../../services/suggestedBindingsApi.js'; import { errorCodeIs } from '@object-ui/types'; +/** + * Whether a cloud install lands in the environment this console renders, read + * off the same two facts `installPackage` routes on. With a cloud base the + * install goes through the same-origin `/cloud-connection/install` proxy, which + * installs into the environment the HOSTNAME names and never reads the picked + * id. Without one it goes to the control plane for the picked environment, + * which is this one only when the runtime named it as its own. + */ +function installLandsInThisEnvironment(selectedEnv: string): boolean { + if (getCloudBase()) return true; + const currentEnvId = getRuntimeConfig().defaultEnvironmentId; + return !!currentEnvId && currentEnvId === selectedEnv; +} + +/** + * objectui#12087 — after an install into THIS environment, the page waits for + * the runtime to serve the package's app before it refreshes the metadata + * cache. `served` carries the first app of the package a user can open, or + * `null` when every one is inactive or hidden. + */ +type DeployState = + | { phase: 'waiting'; manifestId: string } + | { phase: 'served'; manifestId: string; app: ServedApp | null } + | { phase: 'timeout'; manifestId: string }; + export function MarketplacePackagePage() { const navigate = useNavigate(); const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>(); @@ -97,6 +125,10 @@ export function MarketplacePackagePage() { }; const basePath = appName ? `/apps/${appName}` : ''; const { refresh: refreshMetadata } = useMetadata(); + // The adapter `MetadataProvider` reads through: the deploy wait asks the + // same `GET /meta/app` the cache would, without the cache persisting it. + const adapter = useAdapter(); + const { appLabel } = useObjectLabel(); // The runtime's own answer, read once per render -- the same read the // catalog page makes (objectui#5504). `false` means this runtime mounts no // marketplace at all, so there is no package to fetch and nothing to @@ -139,6 +171,7 @@ export function MarketplacePackagePage() { const [acknowledged, setAcknowledged] = useState(false); const [installing, setInstalling] = useState(false); const [installResult, setInstallResult] = useState<{ ok: boolean; message: string } | null>(null); + const [deploy, setDeploy] = useState(null); // Tracks whether the package has been installed into the current // environment via the cloud install path. Used to flip the primary // CTA from "Install to Cloud" → "Installed" so the user gets @@ -307,6 +340,56 @@ export function MarketplacePackagePage() { } }; + /** + * objectui#12087 — wait until this environment serves the installed + * package's app, and only then refresh and persist the metadata cache. + * + * The cache persists every `app` list it fetches (the tab's sessionStorage + * seed) and keeps it in memory with no re-read of its own, so a refresh that + * lands while the runtime still serves the pre-install kernel fixes the + * pre-install list in place: the install "succeeded" and the app never + * appeared. The wait reads `GET /meta/app` past the cache instead, and the + * refresh, the seed drop and the bus pulse all wait for it. On expiry nothing + * is refreshed, and the page says the app has not appeared rather than that + * the install succeeded. + */ + const awaitServedInstall = async (manifestId: string) => { + setDeploy({ phase: 'waiting', manifestId }); + const wait = await waitForServedApp({ + manifestId, + readApps: async () => { + if (!adapter) throw new Error('No data adapter to read the app list through'); + return extractItems(await adapter.getClient().meta.getItems('app')); + }, + }); + if (!wait.served) { + setDeploy({ phase: 'timeout', manifestId }); + return; + } + // Invalidate the metadata cache so the newly-installed app's + // objects/views/menus are fetched fresh on next access, not only the + // `app` list: otherwise a menu entry of the new app fails with "metadata + // not found" against objects/views cached from before the install. + try { + // Drop the persisted seed too — refresh() overwrites it when the new + // list comes back, but clearing first protects against a partial + // failure leaving the pre-install list for the next reload. + if (typeof sessionStorage !== 'undefined') { + for (const key of Object.keys(sessionStorage)) { + if (key.startsWith('objectui:metadata:')) { + sessionStorage.removeItem(key); + } + } + } + await refreshMetadata(); + } catch { + // Non-fatal: the app is served; worst case the user navigates away and + // back to pick up the new metadata. + } + emitMetadataRefresh(); + setDeploy({ phase: 'served', manifestId, app: filterActiveApps(wait.apps)[0] ?? null }); + }; + const doInstall = async () => { if (!packageId || !selectedEnv) return; setInstalling(true); @@ -332,36 +415,18 @@ export function MarketplacePackagePage() { if (info) setCloudInstall(info); } } catch { /* non-fatal */ } - // Invalidate the metadata cache so the newly-installed app's - // objects/views/menus are fetched fresh on next access. Without - // this the user sees the new app in the switcher (the `app` list - // gets refreshed) but clicking a menu entry fails with "metadata - // not found" because objects/views/etc. are still cached from - // before the install. Only useful when installing into the env - // currently rendered by this SPA — for cross-env installs the - // refresh is a harmless no-op (re-fetches the current env's - // metadata, which is unchanged). - const currentEnvId = getRuntimeConfig().defaultEnvironmentId; - if (!currentEnvId || currentEnvId === selectedEnv) { - try { - // Drop the persisted `app` cache too — refresh() overwrites it - // when the new app list comes back, but clearing first protects - // against partial failures leaving stale data on next reload. - if (typeof sessionStorage !== 'undefined') { - for (const key of Object.keys(sessionStorage)) { - if (key.startsWith('objectui:metadata:')) { - sessionStorage.removeItem(key); - } - } - } - await refreshMetadata(); - } catch { - // Non-fatal: install succeeded; worst case the user navigates - // away and back to pick up the new metadata. - } + if (installLandsInThisEnvironment(selectedEnv) && data) { + // objectui#12087 — the runtime answers this install while it still + // serves the PRE-install kernel, so nothing is refreshed yet: the wait + // below refreshes once the package's app is served. Not awaited, and + // not cancelled on unmount: an operator who leaves this page while the + // app deploys still gets the refreshed cache when it lands. + void awaitServedInstall(data.package.manifest_id); + } else { + // Another environment: nothing this console renders changed, so there + // is nothing here to wait for or to refresh. + setInstallResult({ ok: true, message: t('marketplace.install.success') }); } - setInstallResult({ ok: true, message: t('marketplace.install.success') }); - emitMetadataRefresh(); } catch (e: any) { setInstallResult({ ok: false, message: e?.message ?? String(e) }); } finally { @@ -670,6 +735,55 @@ export function MarketplacePackagePage() { ); + // objectui#12087 — the install-into-this-environment status, from the + // install answering until its app is served (or the wait expires). Drawn in + // the install dialog while it is open and on the page once it is closed, so + // closing the dialog does not hide what is still happening. + const deployApp = deploy?.phase === 'served' ? deploy.app : null; + const deployNote = deploy && ( +
+ {deploy.phase === 'waiting' + ?
+ ); + // `suggestFor` is the manifest id whose suggested audience bindings a // successful local INSTALL surfaces (ADR-0090 D5), or `null` where no install // door is drawn and the only result this banner can carry is an uninstall's. @@ -956,6 +1070,8 @@ export function MarketplacePackagePage() { {sampleDataNote} + {!installOpen && deployNote} + {localResultNote(pkg.manifest_id)}
@@ -1111,17 +1227,20 @@ export function MarketplacePackagePage() {
)} - {installResult && ( + {deployNote || (installResult && (
{installResult.message}
- )} + ))} {/* ADR-0090 D5 — after a successful install into THIS runtime, surface the package's suggested audience bindings for the admin to confirm or dismiss (the server never auto-binds). Cross-env - installs are resolved from that env's own Studio instead. */} - {installResult?.ok === true + installs are resolved from that env's own Studio instead. + objectui#12087 — mounted once the app is SERVED: the panel reads + this runtime once, on mount, and before that the runtime still + serves the kernel from before the install. */} + {deploy?.phase === 'served' && getRuntimeConfig().defaultEnvironmentId && getRuntimeConfig().defaultEnvironmentId === selectedEnv && ( @@ -1132,7 +1251,7 @@ export function MarketplacePackagePage() { {!envsError && ( diff --git a/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx new file mode 100644 index 0000000000..f57549b4f9 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * After an install into THIS environment, the page waits until the runtime + * serves the installed package's app before it refreshes and persists the + * metadata cache (objectui#12087). + * + * ## The defect + * + * A cloud install answers once the control plane has written the + * installation. The environment's runtime then rebuilds its kernel and, until + * the rebuild lands, keeps serving the PRE-install kernel (measured ~60 s on + * staging). The page refreshed the metadata cache the moment the install + * answered, so the refresh read the pre-install `app` list, and + * `MetadataProvider` kept it: in memory with no re-read of its own, and in the + * tab's sessionStorage seed. The success message showed; the app never + * appeared in that tab. + * + * ## The harness + * + * The REAL `MetadataProvider` and the REAL assistant bus, over a fake adapter + * whose `GET /meta/app` answers the pre-install list for `staleReads` reads + * after the install, then the post-install one: the stale-while-rebuild window + * in miniature. Every sessionStorage write of the `app` seed is recorded, so + * "never persisted" is asserted on what the provider actually wrote, not on a + * spy of the page's own calls. `waitForServedApp` runs for real with its + * interval and bound shrunk, and its sleeps recorded. + * + * ## The cases + * + * - the stale window: no `app` list without the installed app is persisted + * after the install; the success state and 「打开 」 appear only once + * the app is served, and closing the dialog does not hide them; + * - expiry: the timeout message, no success, nothing persisted, and "Check + * again" starts another wait; + * - CONTROL: an install the runtime serves at once refreshes on the first + * read, with no sleep before it. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; + +const navigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigate, + useParams: () => ({ packageId: 'pkg_1' }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + useAuth: () => ({ activeOrganization: { id: 'org_1' } }), +})); + +// `t` echoes the KEY (and the one interpolated name), as the sibling suites do: +// the claim is WHICH sentence the page picks. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => (opts?.name ? `«${key}:${opts.name}»` : `«${key}»`), + language: 'en', + }), +})); + +const installs: string[] = []; +vi.mock('../marketplaceApi', () => ({ + getMarketplacePackage: async () => PACKAGE, + getCloudInstallationInfo: async () => null, + listLocalInstalls: async () => [], + installPackage: async ({ environmentId }: { environmentId: string }) => { + installs.push(environmentId); + server.installed = true; + return { installation: { id: 'inst_1', environment_id: environmentId, package_id: 'pkg_1', version: '1.0.0' } }; + }, + installLocal: async () => ({}), + uninstallLocal: async () => ({}), + listCloudEnvironments: async () => [], + listInstallableOrgIds: async () => new Set(), + cloudConsoleUrl: () => '', + reseedSampleData: async () => ({ ok: true }), + purgeSampleData: async () => ({ ok: true }), + reseedLocalSampleData: async () => ({ ok: true }), + purgeLocalSampleData: async () => ({ ok: true }), +})); + +// Mounts its own read of the runtime; here it only marks WHEN it mounts. +vi.mock('../../../components/SuggestedBindingsPanel', () => ({ + SuggestedBindingsPanel: () =>
, +})); + +const sleeps: number[] = []; +vi.mock('../waitForServedApp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForServedApp: (o: Parameters[0]) => + actual.waitForServedApp({ + ...o, + intervalMs: 20, + capMs: 200, + sleep: (ms: number) => { + sleeps.push(ms); + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + }), + }; +}); + +import { AdapterCtx, useMetadata } from '@object-ui/react'; +import { ActiveOrganizationStorage } from '@object-ui/auth'; +import { MetadataProvider } from '../../../providers/MetadataProvider'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; +import { MarketplacePackagePage } from '../MarketplacePackagePage'; + +const PKG = 'com.acme.crm'; +const PACKAGE = { + package: { + id: 'pkg_1', + manifest_id: PKG, + display_name: 'Acme CRM', + description: 'A CRM.', + latest_version: { id: 'v1', version: '1.0.0' }, + }, + versions: [{ id: 'v1', version: '1.0.0' }], +}; +const PRE = [{ name: 'setup', label: 'Setup', _packageId: 'com.objectstack.setup' }]; +const POST = [...PRE, { name: 'crm_enterprise', label: 'Acme CRM', _packageId: PKG }]; + +/** The runtime: pre-install list until the install, then for `staleReads` more reads. */ +const server = { installed: false, staleReads: 0, readsSinceInstall: 0 }; + +function makeAdapter() { + return { + clearCache: vi.fn(), + getClient: () => ({ + meta: { + getItems: async (type: string) => { + if (type !== 'app') return { type, items: [] }; + if (!server.installed) return { type, items: PRE }; + const stale = server.readsSinceInstall++ < server.staleReads; + return { type, items: stale ? PRE : POST }; + }, + getItem: async () => ({ item: null }), + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +/** Every write of the `app` seed: whether it came after the install, and whether it carried the app. */ +let seedWrites: Array<{ afterInstall: boolean; hasApp: boolean }> = []; +const realSetItem = Storage.prototype.setItem; + +function AppsProbe() { + const { apps } = useMetadata(); + return {apps.map((a: { name: string }) => a.name).join(',')}; +} + +async function bootAsEnvironmentConsole() { + resetRuntimeConfigForTesting(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + cloudUrl: 'https://cloud.example', + singleEnvironment: true, + defaultEnvironmentId: 'env_1', + features: { installLocal: false, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, + }), + })), + ); + await initRuntimeConfig(); +} + +function renderPage() { + const adapter = makeAdapter(); + return render( + + + + + + , + ); +} + +/** Open the dialog and install into this environment. */ +async function install() { + fireEvent.click(await screen.findByRole('button', { name: /«marketplace\.action\.installToCloud»/ })); + fireEvent.click(await screen.findByRole('button', { name: '«marketplace.action.install»' })); +} + +const staleSeedWrites = () => seedWrites.filter((w) => w.afterInstall && !w.hasApp); + +beforeEach(async () => { + server.installed = false; + server.staleReads = 0; + server.readsSinceInstall = 0; + installs.length = 0; + sleeps.length = 0; + navigate.mockReset(); + seedWrites = []; + sessionStorage.clear(); + ActiveOrganizationStorage.set('org_1'); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + if (key.startsWith('objectui:metadata:app:')) { + const items = JSON.parse(value) as Array<{ _packageId?: string }>; + seedWrites.push({ afterInstall: server.installed, hasApp: items.some((a) => a._packageId === PKG) }); + } + return realSetItem.call(this, key, value); + }); + await bootAsEnvironmentConsole(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); + sessionStorage.clear(); +}); + +describe('MarketplacePackagePage waits for the installed app to be served (objectui#12087)', () => { + it('persists no pre-install app list in the stale window, and offers 「打开」 once the app is served', async () => { + server.staleReads = 3; + renderPage(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup')); + await install(); + + expect(await screen.findByText('«marketplace.install.deploying»')).toBeInTheDocument(); + expect(installs).toEqual(['env_1']); + // No success is claimed, and no suggested bindings read the stale kernel. + expect(screen.queryByText('«marketplace.install.success»')).not.toBeInTheDocument(); + expect(screen.queryByTestId('suggested-bindings')).not.toBeInTheDocument(); + + // Closing the dialog does not hide what is still happening. + fireEvent.click(screen.getByRole('button', { name: '«marketplace.action.close»' })); + const open = await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' }); + expect(screen.getByTestId('install-deploy-status')).toHaveTextContent('«marketplace.install.deployed»'); + + // The wait slept through the stale reads, one interval each. + expect(sleeps).toEqual([20, 20, 20]); + // The cache holds the installed app, in memory and in the persisted seed, + // and no seed write after the install ever lacked it. + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(seedWrites.some((w) => w.afterInstall && w.hasApp)).toBe(true); + expect(staleSeedWrites()).toEqual([]); + + fireEvent.click(open); + expect(navigate).toHaveBeenCalledWith(`/apps/${PKG}`); + }); + + it('on expiry says the app has not appeared, persists nothing, and "Check again" waits again', async () => { + server.staleReads = Number.POSITIVE_INFINITY; + renderPage(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup')); + await install(); + + expect(await screen.findByText('«marketplace.install.deployTimeout»', {}, { timeout: 2000 })).toBeInTheDocument(); + expect(screen.queryByText('«marketplace.install.deployed»')).not.toBeInTheDocument(); + expect(screen.queryByText('«marketplace.install.success»')).not.toBeInTheDocument(); + expect(seedWrites.filter((w) => w.afterInstall)).toEqual([]); + expect(screen.getByTestId('apps').textContent).toBe('setup'); + + // The rebuild lands; asking again finds the app. + server.staleReads = 0; + server.readsSinceInstall = 0; + fireEvent.click(screen.getByRole('button', { name: '«marketplace.install.checkAgain»' })); + expect(await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(staleSeedWrites()).toEqual([]); + }); + + it('CONTROL: an install served at once refreshes on the first read, with no sleep', async () => { + server.staleReads = 0; + renderPage(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup')); + await install(); + + expect(await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' })).toBeInTheDocument(); + expect(sleeps).toEqual([]); + expect(screen.getByTestId('suggested-bindings')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); + expect(staleSeedWrites()).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/console/marketplace/__tests__/waitForServedApp-12087.test.ts b/packages/app-shell/src/console/marketplace/__tests__/waitForServedApp-12087.test.ts new file mode 100644 index 0000000000..819fc35b82 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/waitForServedApp-12087.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `waitForServedApp` — the bounded wait behind objectui#12087. + * + * After a cloud install the runtime keeps serving the pre-install kernel while + * it rebuilds. The wait reads `GET /meta/app` until an item wears the + * installed package's manifest id as `_packageId`, and reports `served: false` + * once its bound expires. These cases drive it with a virtual clock: `sleep` + * advances `now`, so the bound is measured exactly and nothing waits in real + * time. + */ + +import { describe, it, expect } from 'vitest'; +import { + waitForServedApp, + SERVED_APP_POLL_INTERVAL_MS, + SERVED_APP_WAIT_CAP_MS, +} from '../waitForServedApp'; + +const PKG = 'com.acme.crm'; +const PRE = [{ name: 'setup', _packageId: 'com.objectstack.setup' }]; +const CRM = { name: 'crm_enterprise', _packageId: PKG }; +const POST = [...PRE, CRM]; + +/** A runtime that answers the pre-install list for `staleReads` reads, then the post-install one. */ +function runtime(staleReads: number) { + let reads = 0; + return { + get reads() { return reads; }, + readApps: async () => (reads++ < staleReads ? PRE : POST), + }; +} + +function virtualClock() { + let t = 0; + const sleeps: number[] = []; + return { + sleeps, + now: () => t, + sleep: async (ms: number) => { sleeps.push(ms); t += ms; }, + }; +} + +describe('waitForServedApp (objectui#12087)', () => { + it('CONTROL: an app served on the first read settles at once, with no sleep before it', async () => { + const rt = runtime(0); + const clock = virtualClock(); + const wait = await waitForServedApp({ manifestId: PKG, readApps: rt.readApps, ...clock }); + expect(wait).toEqual({ served: true, apps: [CRM] }); + expect(rt.reads).toBe(1); + expect(clock.sleeps).toEqual([]); + }); + + it('keeps reading through the stale window and settles on the first read that serves the app', async () => { + const rt = runtime(3); + const clock = virtualClock(); + const wait = await waitForServedApp({ manifestId: PKG, readApps: rt.readApps, ...clock }); + expect(wait).toEqual({ served: true, apps: [CRM] }); + expect(rt.reads).toBe(4); + expect(clock.sleeps).toEqual([ + SERVED_APP_POLL_INTERVAL_MS, + SERVED_APP_POLL_INTERVAL_MS, + SERVED_APP_POLL_INTERVAL_MS, + ]); + }); + + it('reports served: false once the bound expires, after a bounded number of reads', async () => { + const rt = runtime(Number.POSITIVE_INFINITY); + const clock = virtualClock(); + const wait = await waitForServedApp({ manifestId: PKG, readApps: rt.readApps, ...clock }); + expect(wait).toEqual({ served: false }); + // One read at t=0, then one per interval while the next one still fits the bound. + expect(rt.reads).toBe(Math.floor(SERVED_APP_WAIT_CAP_MS / SERVED_APP_POLL_INTERVAL_MS) + 1); + expect(clock.now()).toBeLessThanOrEqual(SERVED_APP_WAIT_CAP_MS); + }); + + it('honours an explicit interval and cap', async () => { + const rt = runtime(Number.POSITIVE_INFINITY); + const clock = virtualClock(); + const wait = await waitForServedApp({ + manifestId: PKG, readApps: rt.readApps, intervalMs: 10, capMs: 35, ...clock, + }); + expect(wait).toEqual({ served: false }); + expect(rt.reads).toBe(4); + expect(clock.sleeps).toEqual([10, 10, 10]); + }); + + it('a failed read is not an answer: the wait keeps going to the next read', async () => { + let reads = 0; + const clock = virtualClock(); + const wait = await waitForServedApp({ + manifestId: PKG, + readApps: async () => { + reads++; + if (reads === 1) throw new Error('503 while the kernel swaps'); + return POST; + }, + ...clock, + }); + expect(wait).toEqual({ served: true, apps: [CRM] }); + expect(reads).toBe(2); + }); + + it('only an item wearing THIS package id counts: another package, or none, does not', async () => { + const clock = virtualClock(); + const wait = await waitForServedApp({ + manifestId: PKG, + readApps: async () => [ + { name: 'crm_enterprise' }, + { name: 'crm_enterprise', _packageId: 'com.other.crm' }, + null, + 'crm_enterprise', + ], + intervalMs: 10, + capMs: 20, + ...clock, + }); + expect(wait).toEqual({ served: false }); + }); +}); diff --git a/packages/app-shell/src/console/marketplace/waitForServedApp.ts b/packages/app-shell/src/console/marketplace/waitForServedApp.ts new file mode 100644 index 0000000000..868fbdc38b --- /dev/null +++ b/packages/app-shell/src/console/marketplace/waitForServedApp.ts @@ -0,0 +1,112 @@ +/** + * Wait until the environment SERVES the app a package install contributed + * (objectui#12087). + * + * A cloud install answers as soon as the control plane has written the + * installation. The environment's runtime then rebuilds its kernel, and while + * that rebuild runs it keeps serving the PRE-install kernel (stale-while- + * rebuild). The console used to refresh, and persist, its `app` list the moment + * the install answered: the read landed inside that window, so the cached list + * was the pre-install one and the installed app never appeared in the tab that + * installed it. + * + * This asks `GET /meta/app` until the package's app is in the answer, on a + * bound, and reports what it saw. It never writes a cache: the caller refreshes + * and persists only once this says `served`. + * + * ## The predicate + * + * A served app wears its owning package's machine id as `_packageId` (the spec's + * `MetadataProtectionFields`; the same key ADR-0048 routes `/apps/` + * on, see `appRouteSegment`). That id is the package's manifest id, which the + * marketplace row carries as `manifest_id` ("must match the parent + * `sys_package.manifest_id`", `PackageManifestSchema`). So "the installed + * package's app is served" is: some item of the list has + * `_packageId === manifestId`. + * + * ## What it cannot tell apart + * + * The list is the only signal. When the bound expires with no such app, the + * runtime may still be rebuilding, the package may have failed to load, it may + * contribute no app, or its app may be withheld from this user. Nothing the + * console reads distinguishes those, so the caller says so rather than picking + * one. ⛔ The bound is not a guess at how long a rebuild takes. + */ + +/** + * How often one wait reads `GET /meta/app`. It bounds the REQUEST RATE: one + * list read per interval, never a burst, for as long as the wait lasts. + */ +export const SERVED_APP_POLL_INTERVAL_MS = 5_000; + +/** + * How long one wait lasts before it reports that the app has not appeared. It + * bounds the WAIT, not the rebuild: nothing here models how long a runtime + * takes to rebuild, and the caller offers to start another wait. + */ +export const SERVED_APP_WAIT_CAP_MS = 5 * 60_000; + +export interface ServedAppWaitOptions { + /** The installed package's manifest id, matched against each app's `_packageId`. */ + manifestId: string; + /** + * One `GET /meta/app` read, as items. ⛔ Not through the metadata cache: the + * cache persists what it reads, and every read before `served` may be the + * pre-install kernel's answer. + */ + readApps: () => Promise; + intervalMs?: number; + capMs?: number; + /** Injected by tests; defaults to a `setTimeout` promise. */ + sleep?: (ms: number) => Promise; + /** Injected by tests; defaults to `Date.now`. */ + now?: () => number; +} + +/** + * An app as `GET /meta/app` serves it: the members this wait and its caller + * read. `label` is the spec's app label, a plain string or a keyed i18n label. + */ +export type ServedApp = Record & { + name?: unknown; + _packageId?: unknown; + label?: string | { key: string; defaultValue?: string; params?: Record }; +}; + +export type ServedAppWait = + /** The package's apps as the runtime served them, in list order. */ + | { served: true; apps: ServedApp[] } + /** The bound expired and no read carried an app of the package. */ + | { served: false }; + +const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isAppOf(manifestId: string) { + return (item: unknown): item is ServedApp => + !!item && typeof item === 'object' && (item as ServedApp)._packageId === manifestId; +} + +export async function waitForServedApp(options: ServedAppWaitOptions): Promise { + const { + manifestId, + readApps, + intervalMs = SERVED_APP_POLL_INTERVAL_MS, + capMs = SERVED_APP_WAIT_CAP_MS, + sleep = defaultSleep, + now = Date.now, + } = options; + const deadline = now() + capMs; + // The first read goes out at once: an install the runtime already serves + // (a fast rebuild, or none) settles without any wait at all. + for (;;) { + try { + const apps = (await readApps()).filter(isAppOf(manifestId)); + if (apps.length > 0) return { served: true, apps }; + } catch { + // A read that fails while the runtime swaps kernels answers neither way. + // The bound, not the error, ends the wait. + } + if (now() + intervalMs > deadline) return { served: false }; + await sleep(intervalMs); + } +} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 16ccd4eb59..f0a9dd0839 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3779,6 +3779,11 @@ const ar = { localMarketplaceUnavailable: "هذا الوقت التشغيل لا يحتوي على OS_CLOUD_URL مُهيأ.", updateTo: "تحديث → v{{version}}", installedVersion: "مثبت v{{version}}", + deploying: "جارٍ نشر التطبيق…", + deployed: "تم نشر التطبيق وهو جاهز.", + openApp: "فتح {{name}}", + deployTimeout: "تم تسجيل التثبيت، لكن هذه البيئة لا تقدّم التطبيق بعد. قد يكون النشر لا يزال جاريًا — تحقّق مرة أخرى بعد قليل. إذا لم يظهر أبدًا، فربما لم تُحمَّل الحزمة، أو لا تتضمن أي تطبيق، أو يتطلب تطبيقها إذنًا لا تملكه.", + checkAgain: "تحقّق مرة أخرى", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 8de519a2c3..250495297e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3504,6 +3504,11 @@ const de = { localMarketplaceUnavailable: "Dieses Laufzeitsystem hat keine OS_CLOUD_URL konfiguriert.", updateTo: "Aktualisieren → v{{version}}", installedVersion: "Installiert v{{version}}", + deploying: "App wird bereitgestellt…", + deployed: "Die App ist bereitgestellt und einsatzbereit.", + openApp: "{{name}} öffnen", + deployTimeout: "Die Installation wurde erfasst, aber diese Umgebung stellt die App noch nicht bereit. Die Bereitstellung läuft möglicherweise noch – prüfen Sie es gleich erneut. Erscheint sie nie, wurde das Paket womöglich nicht geladen, enthält keine App, oder die App erfordert eine Berechtigung, die Ihnen fehlt.", + checkAgain: "Erneut prüfen", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e31441f3a5..235a547d12 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4123,6 +4123,13 @@ const en = { localMarketplaceUnavailable: 'This runtime has no OS_CLOUD_URL configured, so the marketplace catalog is unreachable.', updateTo: 'Update → v{{version}}', installedVersion: 'Installed v{{version}}', + // objectui#12087 — an install into THIS environment, from its answer + // until the runtime serves the package's app (MarketplacePackagePage). + deploying: 'Deploying the app…', + deployed: 'The app is deployed and ready.', + openApp: 'Open {{name}}', + deployTimeout: 'The install was recorded, but this environment is not serving the app yet. It may still be deploying — check again shortly. If it never appears, the package may not have loaded, may include no app, or its app may need a permission you do not hold.', + checkAgain: 'Check again', }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e434204e4a..1c23ffff8e 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3571,6 +3571,11 @@ const es = { localMarketplaceUnavailable: "Este runtime no tiene configurado OS_CLOUD_URL.", updateTo: "Actualizar → v{{version}}", installedVersion: "Instalado v{{version}}", + deploying: "Desplegando la aplicación…", + deployed: "La aplicación está desplegada y lista.", + openApp: "Abrir {{name}}", + deployTimeout: "La instalación se registró, pero este entorno aún no ofrece la aplicación. Puede que siga desplegándose; vuelve a comprobarlo en breve. Si nunca aparece, es posible que el paquete no se haya cargado, que no incluya ninguna aplicación o que su aplicación requiera un permiso que no tienes.", + checkAgain: "Comprobar de nuevo", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ecfffb6c5b..d9bd37599b 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3568,6 +3568,11 @@ const fr = { localMarketplaceUnavailable: "Ce runtime n'a pas de OS_CLOUD_URL configuré.", updateTo: "Mettre à jour → v{{version}}", installedVersion: "Installé v{{version}}", + deploying: "Déploiement de l'application…", + deployed: "L'application est déployée et prête.", + openApp: "Ouvrir {{name}}", + deployTimeout: "L'installation a été enregistrée, mais cet environnement ne sert pas encore l'application. Le déploiement est peut-être encore en cours : vérifiez à nouveau dans un instant. Si elle n'apparaît jamais, le paquet n'a peut-être pas été chargé, ne contient aucune application, ou son application exige une autorisation que vous n'avez pas.", + checkAgain: "Vérifier à nouveau", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ca8a353895..f573079e18 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3506,6 +3506,11 @@ const ja = { localMarketplaceUnavailable: "このランタイムにはOS_CLOUD_URLが設定されていないため、マーケットプレイスカタログにアクセスできません。", updateTo: "更新 → v{{version}}", installedVersion: "インストール済み v{{version}}", + deploying: "アプリをデプロイしています…", + deployed: "アプリのデプロイが完了しました。", + openApp: "{{name}} を開く", + deployTimeout: "インストールは記録されましたが、この環境ではまだアプリが提供されていません。デプロイ中の可能性があります。しばらくしてから再確認してください。表示されない場合は、パッケージが読み込まれていない、アプリが含まれていない、またはアプリにあなたが持っていない権限が必要な可能性があります。", + checkAgain: "再確認", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 93a4fe9f5b..f636fdd839 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3503,6 +3503,11 @@ const ko = { localMarketplaceUnavailable: "이 런타임에 OS_CLOUD_URL이 구성되지 않았습니다.", updateTo: "업데이트 → v{{version}}", installedVersion: "v{{version}} 설치됨", + deploying: "앱을 배포하는 중…", + deployed: "앱이 배포되어 사용할 준비가 되었습니다.", + openApp: "{{name}} 열기", + deployTimeout: "설치는 기록되었지만 이 환경에서 아직 앱을 제공하지 않습니다. 아직 배포 중일 수 있으니 잠시 후 다시 확인하세요. 계속 나타나지 않으면 패키지가 로드되지 않았거나, 앱이 포함되어 있지 않거나, 앱에 사용자에게 없는 권한이 필요할 수 있습니다.", + checkAgain: "다시 확인", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9df49af97d..15a5718dd4 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3566,6 +3566,11 @@ const pt = { localMarketplaceUnavailable: "Este runtime não tem OS_CLOUD_URL configurado.", updateTo: "Atualizar → v{{version}}", installedVersion: "Instalado v{{version}}", + deploying: "Implantando o aplicativo…", + deployed: "O aplicativo foi implantado e está pronto.", + openApp: "Abrir {{name}}", + deployTimeout: "A instalação foi registrada, mas este ambiente ainda não está servindo o aplicativo. Ele pode ainda estar sendo implantado — verifique novamente em instantes. Se nunca aparecer, o pacote pode não ter sido carregado, pode não incluir nenhum aplicativo ou o aplicativo pode exigir uma permissão que você não tem.", + checkAgain: "Verificar novamente", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 4b18efcdd3..7ee454ed78 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3654,6 +3654,11 @@ const ru = { localMarketplaceUnavailable: "OS_CLOUD_URL не настроен.", updateTo: "Обновить → v{{version}}", installedVersion: "Установлено v{{version}}", + deploying: "Развёртывание приложения…", + deployed: "Приложение развёрнуто и готово к работе.", + openApp: "Открыть {{name}}", + deployTimeout: "Установка записана, но эта среда пока не предоставляет приложение. Возможно, развёртывание ещё идёт — проверьте снова чуть позже. Если оно так и не появится, пакет мог не загрузиться, не содержать приложения или требовать разрешение, которого у вас нет.", + checkAgain: "Проверить снова", }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b89bcf7f11..aea990865c 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3644,6 +3644,11 @@ const zh = { localMarketplaceUnavailable: '本运行时未配置 OS_CLOUD_URL,无法访问应用市场目录。', updateTo: '更新 → v{{version}}', installedVersion: '已安装 v{{version}}', + deploying: '正在部署应用…', + deployed: '应用已部署完成。', + openApp: '打开 {{name}}', + deployTimeout: '安装已记录,但当前环境尚未提供该应用。它可能仍在部署中,请稍后再次检查。如果一直没有出现,可能是软件包未能加载、不包含应用,或其应用需要你尚未拥有的权限。', + checkAgain: '再次检查', }, // objectui#3546 — the MarketplacePage "Your organization" strip. org: { From 461faa568ff646ea74fafd7a317674749d486bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:29:10 +0000 Subject: [PATCH 2/4] test(console): assert the persisted seed before the wait's sleeps in the stale-window case The stale-seed assertion is the claim the case exists for, so it is the one a regression to an in-window refresh should trip first. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .../MarketplacePackagePage.waitServedApp-12087.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx index f57549b4f9..cdbc5d6705 100644 --- a/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx +++ b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.waitServedApp-12087.test.tsx @@ -241,13 +241,13 @@ describe('MarketplacePackagePage waits for the installed app to be served (objec const open = await screen.findByRole('button', { name: '«marketplace.install.openApp:Acme CRM»' }); expect(screen.getByTestId('install-deploy-status')).toHaveTextContent('«marketplace.install.deployed»'); - // The wait slept through the stale reads, one interval each. - expect(sleeps).toEqual([20, 20, 20]); // The cache holds the installed app, in memory and in the persisted seed, // and no seed write after the install ever lacked it. await waitFor(() => expect(screen.getByTestId('apps').textContent).toBe('setup,crm_enterprise')); - expect(seedWrites.some((w) => w.afterInstall && w.hasApp)).toBe(true); expect(staleSeedWrites()).toEqual([]); + expect(seedWrites.some((w) => w.afterInstall && w.hasApp)).toBe(true); + // The wait slept through the stale reads, one interval each. + expect(sleeps).toEqual([20, 20, 20]); fireEvent.click(open); expect(navigate).toHaveBeenCalledWith(`/apps/${PKG}`); From 8810eb0805da410c54ebdb76a207947c74f7aab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:43:16 +0000 Subject: [PATCH 3/4] fix(console): read the SDK's declared app-list items so the package page stays out of the eager closure Importing extractItems, a value of MetadataProvider.tsx, from the lazy package page made rolldown park the provider in the page's chunk, which the entry then imported statically: the console build's declared-lazy-views guard refused it. The deploy wait reads GetMetaItemsResponse.items from the SDK client directly, the shape that client declares. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .../src/console/marketplace/MarketplacePackagePage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx index f22afb2594..d05b459797 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx @@ -68,7 +68,7 @@ import { } from './marketplaceApi.js'; import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js'; import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; -import { extractItems, useMetadata } from '../../providers/MetadataProvider.js'; +import { useMetadata } from '../../providers/MetadataProvider.js'; import { appRouteSegment, filterActiveApps, resolveKeyedI18nLabel } from '../../utils/index.js'; import { waitForServedApp, type ServedApp } from './waitForServedApp.js'; import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../components/SuggestedBindingsPanel.js'; @@ -357,9 +357,13 @@ export function MarketplacePackagePage() { setDeploy({ phase: 'waiting', manifestId }); const wait = await waitForServedApp({ manifestId, + // The SDK's declared list answer (`GetMetaItemsResponse`: `{ type, + // items }`). Not `MetadataProvider`'s `extractItems`: a value import of + // that module from this lazy page made rolldown park the provider in + // this page's chunk, which pulled the page into the eager closure. readApps: async () => { if (!adapter) throw new Error('No data adapter to read the app list through'); - return extractItems(await adapter.getClient().meta.getItems('app')); + return (await adapter.getClient().meta.getItems('app')).items; }, }); if (!wait.served) { From c88aaf17b548eb9a04c006de4c9bf4c28e238d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:39:43 +0000 Subject: [PATCH 4/4] chore(changeset): @object-ui/i18n is a minor bump, since TranslationKeys gains five members `TranslationKeys` is `typeof en`, exported from the `@object-ui/i18n` entry, so the five `marketplace.install` keys widen a published type. The changeset said no type on the package entries changed; it now says that the en pack and TranslationKeys gain five members, and that nothing on the `@object-ui/app-shell` entry changes. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .changeset/12087-install-wait-served.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/12087-install-wait-served.md b/.changeset/12087-install-wait-served.md index a2a27afcce..1ad32ed21f 100644 --- a/.changeset/12087-install-wait-served.md +++ b/.changeset/12087-install-wait-served.md @@ -1,6 +1,6 @@ --- '@object-ui/app-shell': patch -'@object-ui/i18n': patch +'@object-ui/i18n': minor --- After a marketplace install into the environment the console is rendering, the package page waits until that environment serves the installed package's app, and only then refreshes and persists the app list (objectui#12087). @@ -13,4 +13,4 @@ A cloud install answers once the control plane has written the installation. The - The install-time suggested audience bindings mount once the app is served, so their one read reaches the rebuilt runtime. - An install into another environment shows the same success message as before. It no longer refreshes this console's own metadata, which that install did not change. -Five language-pack keys are added under `marketplace.install` (`deploying`, `deployed`, `openApp`, `deployTimeout`, `checkAgain`), in all ten packs. No export, prop or type changes on the package entries. +Five language-pack keys are added under `marketplace.install` (`deploying`, `deployed`, `openApp`, `deployTimeout`, `checkAgain`), in all ten packs. The exported `en` pack, and the `TranslationKeys` type derived from it (`typeof en`, exported from the `@object-ui/i18n` entry), gain these five members under `marketplace.install`, which is why `@object-ui/i18n` is a minor bump. No export, prop or type on the `@object-ui/app-shell` entry changes.