Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/30588-marketplace-lazy-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

perf(i18n, app-shell)!: the marketplace pages' strings leave the eager language packs for a lazy `marketplace` namespace

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->

The console loads one language pack eagerly, `en`, and its chunk had grown to 59,951 of the 60,000 gzipped bytes its ceiling allows. About 3 KB of it was the `marketplace.*` group, read only by the two marketplace pages, which are lazy routes. That group now lives in `@object-ui/i18n/namespaces/marketplace`: ten catalogues, one per built-in language, each its own chunk, fetched when a marketplace page opens. The console's `en` chunk reads 57,010 bytes.

- **The pages wait for their text.** App-shell's marketplace catalogue and package pages render their load skeleton until the catalogue for the console's language (and `en`, the fallback) has arrived, and again after a language switch, so a visitor never sees English in a translated console or a raw key. A catalogue that cannot be fetched shows the console's own "Something went wrong" with Try again, which fetches it again.
- **`useMarketplaceNamespace()`** (from the new subpath) is the hook behind that frame: it answers `loading`, `ready` or `failed` with a `retry()`, merges the catalogues into the page's i18next instance without overwriting a host's own `resources`, and uses a catalogue already made resident by `registerBuiltInLocales()` without fetching it.
- **What stays in the packs.** Studio's packages page, outside the marketplace route, draws marketplace installs with the same components: their keys (`marketplace.installedAdditiveNote`, `marketplace.notLoaded.*`, `marketplace.action.uninstall`, `marketplace.action.uninstalling`, `marketplace.action.details`, `marketplace.uninstall.confirm`, `marketplace.uninstall.confirmNotLoaded`, `marketplace.uninstall.successInList`, `marketplace.uninstall.successNotLoaded`, `marketplace.install.dependencyMissing.*` and `marketplace.install.protocolCheckSkipped.*`) stay in every pack.
- **Every string still in every language.** No text changed. `@object-ui/i18n/locales` (the all-ten door) hands out each language complete, its pack merged with its namespace catalogue, so key parity and `registerBuiltInLocales()` still cover the moved keys.

**BREAKING** (`@object-ui/i18n`, TypeScript and the pack modules): the package entry's `en`, the exported `TranslationKeys` (`typeof en`) and the pack modules `@object-ui/i18n/locales/<code>` no longer hold the moved `marketplace.*` keys. A `t()` call that names one still compiles, since its key is a `string`. A component outside app-shell's marketplace pages that reads one of those keys calls `useMarketplaceNamespace()` and renders it once the answer is `ready`; code that reads the catalogue objects directly reads them from `@object-ui/i18n/locales`, whose ten languages are complete.
40 changes: 40 additions & 0 deletions ui/apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,46 @@ export default defineConfig({
{ name: 'i18n-ns-studio-en', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]studio[\\/]en\.ts$/, priority: 84 },
{ name: 'i18n-ns-studio-zh', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]studio[\\/]zh\.ts$/, priority: 84 },
//
// The `marketplace` namespace (objectstack#30588): the pack group the
// two marketplace pages read, moved out of the ten packs so the
// eager `i18n-locale-en` chunk stops carrying it. All ten catalogues
// are `import()`ed by `packages/i18n/src/namespaces/marketplace/index.ts`
// when a marketplace page mounts, none is resident, so one group per
// catalogue for the rule above. The loader module gets a group too:
// without one it would fall to `i18n-runtime` below and be eager,
// while its only reader is the marketplace pages' frame.
//
// ⛔ Both the loader's group and the frame's (`marketplace-namespace-gate`,
// app-shell's `MarketplaceNamespaceGate.tsx`, the one module that
// imports the loader) take `includeDependenciesRecursively: false`,
// and the frame needs its group at all, for two measured reasons
// (console build of objectstack#30588, eager closure read from
// `eager-closure.json`):
// - with rolldown's default the loader's group followed its imports
// and took `locales/registry.ts` out of `i18n-runtime` and the
// dynamic-import preload helper out of `framework`; both are
// eager, so the group became an eager 2,374 B gz chunk;
// - without a group of its own the frame, shared by the two lazy
// marketplace pages, landed in the chunk those pages share with
// the eager Studio packages page (`MarketplaceRowActions.tsx` and
// the refusal readers), which is EAGER by that co-tenancy, and
// pulled the loader into the eager closure with it (+1,281 B gz).
// With both, the loader, the frame and the ten catalogues are lazy,
// the shared chunk is the one the base build emits, and the only
// change to the eager closure is `i18n-locale-en`'s.
{ name: 'i18n-ns-marketplace', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]index\.ts$/, priority: 84, includeDependenciesRecursively: false },
{ name: 'i18n-ns-marketplace-en', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]en\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-zh', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]zh\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-ja', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]ja\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-ko', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]ko\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-de', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]de\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-fr', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]fr\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-es', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]es\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-pt', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]pt\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]ru\.ts$/, priority: 84 },
{ name: 'i18n-ns-marketplace-ar', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]namespaces[\\/]marketplace[\\/]ar\.ts$/, priority: 84 },
{ name: 'marketplace-namespace-gate', test: /[\\/]packages[\\/]app-shell[\\/]src[\\/]console[\\/]marketplace[\\/](?:MarketplaceNamespaceGate\.tsx|marketplaceFrameHolding\.ts)$/, priority: 84, includeDependenciesRecursively: false },
//
// ⛔ `i18n-runtime` takes `includeDependenciesRecursively: false`
// for the reason `data-adapter` below does (objectui#9345, read out
// there in full): it outranks `framework` (83 over 80), and since
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The marketplace pages' frame for their own strings (objectstack#30588).
*
* The `marketplace.*` keys these pages read are not in the console's eager
* language pack: they are `@object-ui/i18n`'s lazy `marketplace` namespace,
* fetched when a page here mounts (the namespace module's header says why).
* This frame renders the page only once the namespace answers `ready` for the
* instance's language, so a visitor sees the copy-free load skeleton the pages
* already use (`MarketplaceResolving`), never an English sentence in a
* translated console nor a raw key. A language switch while a page is open
* shows the skeleton again until that language's catalogue has arrived.
*
* That swap unmounts the page body while the visitor stays on the page, so an
* unmount cleanup that means "the visitor left" (`SampleDataLeaveBanner`)
* asks `useMarketplaceFrameHolding()` (`marketplaceFrameHolding.ts`) first.
*
* A catalogue that cannot be fetched is said once, in the console's own error
* words (resident pack keys, which need no fetch), with a Try again that asks
* for it again — never a skeleton that waits forever, and never a retry loop.
*
* ⛔ Every component in this directory that reads a `marketplace.*` key renders
* under this frame (the two pages wrap their bodies in it), or is one of the
* files Studio's packages page also renders — `MarketplaceRowActions.tsx`,
* `dependencyMissing.ts`, `protocolCheckSkipped.ts` — whose keys stayed in
* the packs for that reason and need no frame.
* `__tests__/marketplaceNamespaceBoundary.test.ts` holds both halves.
*/

import { useCallback, useLayoutEffect, useRef, type ReactNode } from 'react';
import { RotateCcw } from 'lucide-react';
import { Button, Empty, EmptyTitle } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { useMarketplaceNamespace } from '@object-ui/i18n/namespaces/marketplace';
import { MarketplaceResolving } from './MarketplaceResolving.js';
import { MarketplaceFrameHolding } from './marketplaceFrameHolding.js';

export function MarketplaceNamespaceGate({ children }: { children: ReactNode }) {
const namespace = useMarketplaceNamespace();
const holding = useRef(false);
useLayoutEffect(() => {
holding.current = namespace.status !== 'ready';
});
const isHolding = useCallback(() => holding.current, []);
return (
<MarketplaceFrameHolding.Provider value={isHolding}>
{namespace.status === 'ready' ? (
children
) : namespace.status === 'failed' ? (
<MarketplaceNamespaceFailed onRetry={namespace.retry} />
) : (
<MarketplaceResolving />
)}
</MarketplaceFrameHolding.Provider>
);
}

function MarketplaceNamespaceFailed({ onRetry }: { onRetry: () => void }) {
const { t } = useObjectTranslation();
return (
<div
className="mx-auto flex w-full max-w-6xl items-center justify-center p-8"
data-testid="marketplace-namespace-failed"
>
<Empty>
<EmptyTitle>{t('console.errors.somethingWentWrong')}</EmptyTitle>
<Button variant="outline" size="sm" onClick={onRetry} className="mt-4 gap-2">
<RotateCcw className="h-4 w-4" />
{t('console.errors.tryAgain')}
</Button>
</Empty>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from '../../com
import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
import type { SuggestedBinding } from '../../services/suggestedBindingsApi.js';
import { errorCodeIs } from '@object-ui/types';
import { MarketplaceNamespaceGate } from './MarketplaceNamespaceGate.js';

/**
* Whether a cloud install lands in the environment this console renders, read
Expand Down Expand Up @@ -163,7 +164,19 @@ function versionEntryId(version: string): string {
return `marketplace-version-${version}`;
}

/**
* The page under the frame that fetches its strings first — the
* `marketplace` namespace (`MarketplaceNamespaceGate.tsx` says why).
*/
export function MarketplacePackagePage() {
return (
<MarketplaceNamespaceGate>
<MarketplacePackagePageBody />
</MarketplaceNamespaceGate>
);
}

function MarketplacePackagePageBody() {
const navigate = useNavigate();
const { packageId, appName } = useParams<{ packageId?: string; appName?: string }>();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
Expand Down
13 changes: 13 additions & 0 deletions ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
import { packagesHref } from '../../views/metadata-admin/packageSource.js';
import { MarketplaceNamespaceGate } from './MarketplaceNamespaceGate.js';

/**
* The card grid's track (objectui#12616): a card is never narrower than 240px,
Expand Down Expand Up @@ -306,7 +307,19 @@ type OrgDeployState =
| { phase: 'served'; manifestId: string; version: string | undefined; name: string; app: ServedApp | null }
| { phase: 'timeout'; manifestId: string; version: string | undefined; name: string };

/**
* The page under the frame that fetches its strings first — the
* `marketplace` namespace (`MarketplaceNamespaceGate.tsx` says why).
*/
export function MarketplacePage() {
return (
<MarketplaceNamespaceGate>
<MarketplacePageBody />
</MarketplaceNamespaceGate>
);
}

function MarketplacePageBody() {
const navigate = useNavigate();
const { appName } = useParams();
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@
* It is raised only for a package this session recorded loading sample data
* into (`sampleDataNotice.ts`), and only while a notification provider is
* mounted: the page renders this component under that guard, since
* `useNotifications()` throws outside one.
* `useNotifications()` throws outside one. A language switch that sends the
* page's frame back to its load skeleton unmounts this component too, with
* the installer still on the page: that is not a leave, so no banner is
* raised (`useMarketplaceFrameHolding`).
*/

import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
import { attachBanner, requestClearConfirm, sampleDataNotice, withdrawBanner } from './sampleDataNotice.js';
import { useMarketplaceFrameHolding } from './marketplaceFrameHolding.js';

export function SampleDataLeaveBanner({ manifestId }: { manifestId: string | null }) {
const { notify, dismiss } = useNotifications();
const { t } = useObjectTranslation();
const navigate = useNavigate();
const isFrameHolding = useMarketplaceFrameHolding();
// The cleanup below runs once the page is gone, so it reads the functions
// of the last render rather than those of the render that set it up.
const latest = useRef({ notify, dismiss, t, navigate });
Expand All @@ -41,6 +46,8 @@ export function SampleDataLeaveBanner({ manifestId }: { manifestId: string | nul
// Back on the package page: its install note speaks for the sample data.
withdrawBanner(manifestId);
return () => {
// The frame took the page back for a language's strings: still here.
if (isFrameHolding?.()) return;
const notice = sampleDataNotice(manifestId);
if (!notice) return;
const { notify: raise, dismiss: drop, t: tr, navigate: go } = latest.current;
Expand All @@ -60,7 +67,7 @@ export function SampleDataLeaveBanner({ manifestId }: { manifestId: string | nul
});
attachBanner(notice.manifestId, () => drop(id));
};
}, [manifestId]);
}, [manifestId, isFrameHolding]);

return null;
}
Loading
Loading