diff --git a/.changeset/11678-recent-items-identity.md b/.changeset/11678-recent-items-identity.md
new file mode 100644
index 0000000000..f73eb84a08
--- /dev/null
+++ b/.changeset/11678-recent-items-identity.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/app-shell': minor
+---
+
+"Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678).
+
+The route tracker used to store a label it made from the route's machine name at visit time: a dashboard labelled "Delivery Operations" was listed as "Showcase Ops Dashboard", and stayed in English after switching to 中文. An object, dashboard, page or report entry now stores its identity only, `type` and `name`, and every surface that lists recent items (the Home cards and rail, the sidebar's Recent group, Studio home, the command palette) labels it when it renders, through the new `useRecentItemLabel` hook. That hook reuses the resolver each kind already has: the navigation target label for an object or a dashboard, and the page's or report's metadata label through the `pageLabel` / `reportLabel` bundle lookup. An item whose metadata is not loaded shows its machine name until it is. A record keeps the title it was visited under, and a Studio metadata item its name.
+
+Lists already stored in the old shape are read by identity, which was always in the entry's `id`; their stored label is dropped, so they show the current label on first render.
+
+`RecentItemsProvider` now refuses to write a list equal to the one it holds, whoever reports the visit: revisiting the item already at the head of the list changes nothing and writes nothing, and its `visitedAt` stays the time it became the most recent entry. Going from an object's list to one of its views no longer saves the list a second time.
+
+**Breaking (types):** `RecentItem` is now a union. `RecentNamedItem` (`object`, `dashboard`, `page`, `report`) carries `name` and no `label`; `RecentTextItem` (`record`, `metadata`) carries `label`. `addRecentItem` takes `RecentItemInput`, so a call that passes a `label` for an object, dashboard, page or report no longer compiles, and code that reads `item.label` off a recent entry should call `useRecentItemLabel()` instead. `useRecentItemLabel`, `RecentItemLabelResolver`, `RecentItemInput`, `RecentItemType`, `RecentNamedItem` and `RecentTextItem` are new exports of `@object-ui/app-shell`.
+
+**Clause-②: yes (narrowing).** The package entry gains six exports, and `addRecentItem` refuses the label-carrying shape it used to take for those four kinds.
diff --git a/content/docs/guide/user-state-persistence.md b/content/docs/guide/user-state-persistence.md
index e33e78f2b0..a020557700 100644
--- a/content/docs/guide/user-state-persistence.md
+++ b/content/docs/guide/user-state-persistence.md
@@ -185,6 +185,12 @@ The providers debounce backend writes with a 500ms window via `createDebouncedFl
`localStorage` is written **synchronously** on every mutation, so reloads always show the latest state immediately even if the debounced backend write hasn't fired yet.
+## Recent items store identity, not labels
+
+An object, dashboard, page or report entry in the recent list is stored as its `type` and `name` (plus `href` and `visitedAt`), never as display text, so a rename or a language switch shows on the next render instead of being frozen at visit time. Surfaces label an entry with `useRecentItemLabel()`, which asks the resolver the console already uses for that kind; an item whose metadata is not loaded yet shows its machine name. Record entries (`type: 'record'`) and Studio metadata entries (`type: 'metadata'`) carry a `label`: a record's title, a metadata item's name.
+
+The provider writes nothing when a visit leaves the list unchanged: re-adding the entry already at the head keeps its `visitedAt` and costs no `save()`. Lists persisted in the earlier label-carrying shape are read by identity (the name is the part of `id` after `type:`) and their stored label is dropped.
+
## Race-safety
Each provider keeps a monotonic `hydrationToken`. If the user switches accounts while a `load()` is in flight, the late response is discarded. This prevents seeing User A's favorites flash into User B's session.
@@ -199,6 +205,7 @@ Each provider keeps a monotonic `hydrationToken`. If the user switches accounts
| `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds }` |
| `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins }`. |
| `useRecentItems()` | `@object-ui/app-shell` | `{ recentItems, addRecentItem, clearRecentItems }` |
+| `useRecentItemLabel()` | `@object-ui/app-shell` | `(item) => string` — a recent entry's label, resolved on render from the item's metadata in the current language. |
| `useFlowPaletteRecents()` | `@object-ui/app-shell` | `{ recents, recordRecent }` — flow-designer add-node MRU; falls back to localStorage outside a provider. |
| `createObjectStackUserStateAdapter(opts)` | `@object-ui/data-objectstack` | Official adapter over the platform's `sys_user_preference` store. `{ dataSource, userId, key, resource?, onError? }` — one instance per `(user, key)` pair. |
diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md
index 281ba7edc4..70715a36f4 100644
--- a/packages/app-shell/README.md
+++ b/packages/app-shell/README.md
@@ -954,6 +954,23 @@ const { recentItems, addRecentItem } = useRecentItems();
const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();
```
+A recent object, dashboard, page or report entry stores its identity only
+(`type` and `name`), never display text: label it where you render it with
+`useRecentItemLabel()`, which reads the item's current metadata label in the
+current language (a record entry keeps the title it was visited under). The
+provider writes nothing when a visit leaves the list unchanged, such as
+revisiting the item already at its head.
+
+```tsx
+import { useRecentItems, useRecentItemLabel } from '@object-ui/app-shell';
+
+function RecentList() {
+ const { recentItems } = useRecentItems();
+ const recentLabel = useRecentItemLabel();
+ return
{recentItems.map((item) => {recentLabel(item)} )} ;
+}
+```
+
Nav pins and Favorites share a single `favorites` collection. `FavoriteItem`
carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single
adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx
index 0e643c454b..3d7ad3a263 100644
--- a/packages/app-shell/src/chrome/CommandPalette.tsx
+++ b/packages/app-shell/src/chrome/CommandPalette.tsx
@@ -35,6 +35,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils/index.js';
import { getIcon } from '../utils/getIcon.js';
import { useRecentItems } from '../context/RecentItemsProvider.js';
+import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js';
import { useCommandPalette } from '../context/CommandPaletteProvider.js';
import { resolveHref, resolveNavItemLabel } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
@@ -125,6 +126,9 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
// user types anything. Filtered down to record-type entries so we
// don't double up with the per-app nav above.
const { recentItems } = useRecentItems();
+ // The one way a recent entry is labelled (objectui#11678); for a record it
+ // is the title the entry was visited under.
+ const recentLabel = useRecentItemLabel();
const recentRecords = useMemo(
() => recentItems.filter((it) => it.type === 'record').slice(0, 5),
[recentItems],
@@ -215,11 +219,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
{recentRecords.map((item) => (
runCommand(() => navigate(item.href))}
>
- {item.label}
+ {recentLabel(item)}
))}
diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx
index 2a7965f41f..d234de48cb 100644
--- a/packages/app-shell/src/console/home/HomePage.tsx
+++ b/packages/app-shell/src/console/home/HomePage.tsx
@@ -19,6 +19,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { useRecentItems } from '../../hooks/useRecentItems.js';
+import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js';
import { useFavorites } from '../../hooks/useFavorites.js';
import { useObjectTranslation } from '@object-ui/i18n';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
@@ -237,6 +238,9 @@ export function HomePage() {
const { t } = useObjectTranslation();
const { apps, loading } = useMetadata();
const { recentItems } = useRecentItems();
+ // A recent entry stores identity; the rail labels it with this, on the
+ // render, in the current language (objectui#11678).
+ const recentLabel = useRecentItemLabel();
const { favorites } = useFavorites();
const { user } = useAuth();
const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus();
@@ -593,7 +597,7 @@ export function HomePage() {
{/* Continue where you left off + ambient activity */}
- navigate(href)} t={t} />
+ navigate(href)} t={t} labelOf={recentLabel} />
navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx
index d830bf11a9..ebb8def5c8 100644
--- a/packages/app-shell/src/console/home/HomeRail.tsx
+++ b/packages/app-shell/src/console/home/HomeRail.tsx
@@ -20,6 +20,7 @@ import { useDisplayLocale } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed.js';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
+import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import { timeAgo } from '../../utils/relativeTime.js';
@@ -236,7 +237,24 @@ const RECENT_TONE: Record = {
page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};
-export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen: (href: string) => void; t: TFn }) {
+/**
+ * `labelOf` is `useRecentItemLabel()`, handed in by the page like `t`: an entry
+ * stores identity, not text, so its label is resolved on the render, in the
+ * current language (objectui#11678). Taken as a prop so this module — which
+ * also holds the action center and the activity card — does not itself load
+ * the metadata cache the resolver reads.
+ */
+export function HomeContinue({
+ items,
+ onOpen,
+ t,
+ labelOf,
+}: {
+ items: RecentItem[];
+ onOpen: (href: string) => void;
+ t: TFn;
+ labelOf: RecentItemLabelResolver;
+}) {
return (
{items.length === 0 ? (
@@ -250,7 +268,7 @@ export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen
key={it.id}
icon={RECENT_ICON[it.type] || FileText}
iconClass={RECENT_TONE[it.type] || 'bg-muted text-muted-foreground'}
- label={it.label}
+ label={labelOf(it)}
meta={recentItemTypeLabel(t, it.type)}
onClick={() => onOpen(it.href)}
/>
diff --git a/packages/app-shell/src/console/home/RecentApps.tsx b/packages/app-shell/src/console/home/RecentApps.tsx
index ba64b6d290..e52fbf30ae 100644
--- a/packages/app-shell/src/console/home/RecentApps.tsx
+++ b/packages/app-shell/src/console/home/RecentApps.tsx
@@ -12,6 +12,7 @@ import { Card, CardContent, cn } from '@object-ui/components';
import { Clock, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react';
import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
+import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js';
interface RecentAppsProps {
items: RecentItem[];
@@ -36,6 +37,9 @@ const TYPE_ICONS: Record> =
export function RecentApps({ items }: RecentAppsProps) {
const navigate = useNavigate();
const { t } = useObjectTranslation();
+ // The entry stores identity, not text: its label is the item's own, read in
+ // the current language on this render (objectui#11678).
+ const recentLabel = useRecentItemLabel();
if (items.length === 0) return null;
@@ -75,7 +79,7 @@ export function RecentApps({ items }: RecentAppsProps) {
-
{item.label}
+
{recentLabel(item)}
{typeLabel}
diff --git a/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx b/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx
index b52f25cd06..f0494f3c1e 100644
--- a/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx
+++ b/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx
@@ -62,6 +62,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => {
import { useObjectTranslation } from '@object-ui/i18n';
import { HomeContinue } from '../HomeRail.js';
+import { useRecentItemLabel } from '../../../hooks/useRecentItemLabel.js';
import { RecentApps } from '../RecentApps.js';
import { StarredApps } from '../StarredApps.js';
@@ -79,7 +80,9 @@ const ITEM_LABEL = 'Quarterly revenue';
*/
function Rail({ items }: { items: RecentItem[] }) {
const { t } = useObjectTranslation();
- return {}} t={t} />;
+ // HomePage hands the rail its labeller the same way (objectui#11678).
+ const labelOf = useRecentItemLabel();
+ return {}} t={t} labelOf={labelOf} />;
}
/**
@@ -98,15 +101,21 @@ function typeLabelOf(node: HTMLElement, itemLabel: string): string {
}
function renderAllThree(kind: string) {
- const recent: RecentItem[] = [
+ // A synthetic kind is no member of the `RecentItem` union, so the entry is
+ // cast. It carries BOTH `name` (what a named kind — `report` here — is
+ // labelled from when no metadata is loaded) and `label` (what any other kind
+ // shows), so every surface renders ITEM_LABEL whichever arm the kind falls in
+ // (objectui#11678).
+ const recent = [
{
id: 'r1',
+ name: ITEM_LABEL,
label: ITEM_LABEL,
href: '/x',
- type: kind as RecentItem['type'],
+ type: kind,
visitedAt: new Date().toISOString(),
},
- ];
+ ] as unknown as RecentItem[];
const favorites: FavoriteItem[] = [
{
id: 'f1',
diff --git a/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx b/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx
new file mode 100644
index 0000000000..0d8f66ac92
--- /dev/null
+++ b/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx
@@ -0,0 +1,258 @@
+/**
+ * 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.
+ */
+
+/**
+ * "Recently Accessed" shows each item's OWN label, resolved when it is
+ * rendered, in the current language (objectui#11678).
+ *
+ * The card measured /home after visiting the showcase's Delivery Operations
+ * dashboard, its Team Schedule page and its Capability Map page: the list read
+ * "Showcase Ops Dashboard", "Showcase Task Schedule", "Showcase Capability
+ * Map" — `titleize` run over the route's machine names at visit time, stored
+ * in `ui.recent`, and still English after switching to 中文.
+ *
+ * Driven end to end with real parts: the route tracker records the three
+ * visits, the real `RecentItemsProvider` stores them, and both list surfaces
+ * render them through `useRecentItemLabel` over a real
+ * `MetadataCtx` and a real i18next instance: the /home rail (`HomeContinue`,
+ * which `HomePage` hands the resolver as `labelOf`) and the `RecentApps` cards
+ * (which call it themselves). The metadata is what the `/meta`
+ * read serves in each language (the console re-reads it on a language switch,
+ * `MetadataProvider key={language}`), and the zh-CN bundle carries one page
+ * label, so the three entries take three different roads to their zh-CN text:
+ *
+ * - the dashboard: its served label, translated by the server;
+ * - Team Schedule: its served label is English, the client bundle's
+ * `pages..label` key translates it;
+ * - Capability Map: translated nowhere — it shows its own label, which is
+ * what its own page shows, and never the minted name.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { ComponentType } from 'react';
+import { act, cleanup, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { MetadataCtx } from '@object-ui/react';
+import { createI18n, I18nProvider, useObjectTranslation } from '@object-ui/i18n';
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u1' }, isAuthenticated: true, isLoading: false }),
+}));
+
+import { RecentApps } from '../RecentApps.js';
+import { HomeContinue } from '../HomeRail.js';
+import { useRecentItemLabel } from '../../../hooks/useRecentItemLabel.js';
+import { RecentItemsProvider, useRecentItems } from '../../../context/RecentItemsProvider.js';
+import { UserStateAdaptersProvider } from '../../../context/UserStateAdapters.js';
+import { useTrackRouteAsRecent } from '../../../hooks/useTrackRouteAsRecent.js';
+
+const APP = 'showcase_app';
+const DASHBOARD = `/apps/${APP}/dashboard/showcase_ops_dashboard`;
+const SCHEDULE = `/apps/${APP}/page/showcase_task_schedule`;
+const CAPABILITY_MAP = `/apps/${APP}/page/showcase_capability_map`;
+
+/** What the `/meta` read serves, per language — labels as the showcase authors them. */
+const SERVED = {
+ en: {
+ dashboards: [{ name: 'showcase_ops_dashboard', label: 'Delivery Operations' }],
+ pages: [
+ { name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)' },
+ { name: 'showcase_capability_map', label: 'Capability Map' },
+ ],
+ objects: [{ name: 'showcase_task', label: 'Tasks' }],
+ },
+ 'zh-CN': {
+ dashboards: [{ name: 'showcase_ops_dashboard', label: '交付运营' }],
+ pages: [
+ { name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)' },
+ { name: 'showcase_capability_map', label: 'Capability Map' },
+ ],
+ objects: [{ name: 'showcase_task', label: '任务' }],
+ },
+} as const;
+
+/** The minted text the old tracker stored — none of it may reach the screen. */
+const MINTED = ['Showcase Ops Dashboard', 'Showcase Task Schedule', 'Showcase Capability Map'];
+
+function metadata(language: keyof typeof SERVED) {
+ const served = SERVED[language];
+ return {
+ apps: [{ name: APP, label: 'Showcase' }],
+ objects: served.objects,
+ dashboards: served.dashboards,
+ reports: [],
+ pages: served.pages,
+ loading: false,
+ error: null,
+ refresh: async () => {},
+ invalidate: () => {},
+ ensureType: async () => [],
+ getItem: async () => null,
+ getItemsByType: () => [],
+ getTypeStatus: () => 'ready' as const,
+ };
+}
+
+function newI18n() {
+ return createI18n({
+ defaultLanguage: 'en',
+ detectBrowserLanguage: false,
+ resources: {
+ // The app bundle's page-label key (`useObjectLabel().pageLabel`).
+ 'zh-CN': { showcase: { pages: { showcase_task_schedule: { label: '团队排期' } } } },
+ },
+ });
+}
+
+function Visit({ pathname }: { pathname: string }) {
+ useTrackRouteAsRecent({ pathname, appName: APP, objects: [...SERVED.en.objects] });
+ return null;
+}
+
+function Home() {
+ const { recentItems } = useRecentItems();
+ return ;
+}
+
+/** The /home rail, wired as `HomePage` wires it. */
+function Rail() {
+ const { recentItems } = useRecentItems();
+ const { t } = useObjectTranslation();
+ const labelOf = useRecentItemLabel();
+ return {}} t={t} labelOf={labelOf} />;
+}
+
+function tree(
+ i18n: ReturnType,
+ language: keyof typeof SERVED,
+ pathname: string,
+ Surface: ComponentType = Home,
+) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** The rail's row labels, most recent first. */
+function railLabels(): string[] {
+ return screen
+ .getAllByRole('button')
+ .map((row) => (row.querySelector('span.truncate')?.textContent ?? '').trim());
+}
+
+/** The card titles, most recent first. */
+function shownLabels(): string[] {
+ return screen
+ .getAllByTestId(/^recent-item-/)
+ .map((card) => (card.querySelector('h3')?.textContent ?? '').trim());
+}
+
+beforeEach(() => {
+ localStorage.clear();
+});
+afterEach(() => cleanup());
+
+describe('"Recently Accessed" labels each item when it is rendered (objectui#11678)', () => {
+ it('the three measured pages show their own labels in en, then in zh-CN — the same stored entries', async () => {
+ const i18n = newI18n();
+ const view = render(tree(i18n, 'en', DASHBOARD));
+ view.rerender(tree(i18n, 'en', SCHEDULE));
+ view.rerender(tree(i18n, 'en', CAPABILITY_MAP));
+
+ expect(shownLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations']);
+
+ const stored = localStorage.getItem('objectui-recent-items:u:u1') ?? '';
+ // Instrument check: the visits were stored, so the next read is of a real list.
+ expect(stored).toContain('showcase_ops_dashboard');
+ // What is stored is identity: no label of any language, and no minted text.
+ for (const text of [...MINTED, 'Delivery Operations', 'Team Schedule', 'Capability Map']) {
+ expect(stored).not.toContain(text);
+ }
+
+ // Switch to 中文. Nothing is re-visited: the stored list is unchanged and
+ // only the render's language (and the metadata served in it) moved.
+ await act(async () => {
+ await i18n.changeLanguage('zh-CN');
+ });
+ view.rerender(tree(i18n, 'zh-CN', CAPABILITY_MAP));
+
+ expect(shownLabels()).toEqual(['Capability Map', '团队排期', '交付运营']);
+ expect(localStorage.getItem('objectui-recent-items:u:u1')).toBe(stored);
+ });
+
+ it('the /home rail shows the same labels, in en then zh-CN', async () => {
+ const i18n = newI18n();
+ const view = render(tree(i18n, 'en', DASHBOARD, Rail));
+ view.rerender(tree(i18n, 'en', SCHEDULE, Rail));
+ view.rerender(tree(i18n, 'en', CAPABILITY_MAP, Rail));
+ expect(railLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations']);
+
+ await act(async () => {
+ await i18n.changeLanguage('zh-CN');
+ });
+ view.rerender(tree(i18n, 'zh-CN', CAPABILITY_MAP, Rail));
+ expect(railLabels()).toEqual(['Capability Map', '团队排期', '交付运营']);
+ for (const text of MINTED) expect(screen.queryByText(text)).toBeNull();
+ });
+
+ it('a list stored before the identity shape shows the items’ current labels, not the minted text', () => {
+ localStorage.setItem(
+ 'objectui-recent-items:u:u1',
+ JSON.stringify([
+ { id: 'page:showcase_capability_map', label: 'Showcase Capability Map', href: CAPABILITY_MAP, type: 'page', visitedAt: '2026-10-06T09:02:00.000Z' },
+ { id: 'page:showcase_task_schedule', label: 'Showcase Task Schedule', href: SCHEDULE, type: 'page', visitedAt: '2026-10-06T09:01:00.000Z' },
+ { id: 'dashboard:showcase_ops_dashboard', label: 'Showcase Ops Dashboard', href: DASHBOARD, type: 'dashboard', visitedAt: '2026-10-06T09:00:00.000Z' },
+ // Renamed since the visit: the stored "Task" is stale, the object is "Tasks" now.
+ { id: 'object:showcase_task', label: 'Task', href: `/apps/${APP}/showcase_task`, type: 'object', visitedAt: '2026-10-06T08:59:00.000Z' },
+ ]),
+ );
+ // `/home` — the tracker records nothing here.
+ render(tree(newI18n(), 'en', '/home'));
+
+ expect(shownLabels()).toEqual(['Capability Map', 'Team Schedule (Gantt)', 'Delivery Operations', 'Tasks']);
+ for (const text of MINTED) expect(screen.queryByText(text)).toBeNull();
+ });
+
+ it('an item whose metadata has not loaded shows its machine name, then its label once it arrives', () => {
+ const i18n = newI18n();
+ const empty = { ...metadata('en'), dashboards: [], pages: [] };
+ const ui = (value: unknown) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ const view = render(ui(empty));
+ // The navigation renderer's backstop, not a titleized guess.
+ expect(shownLabels()).toEqual(['showcase_ops_dashboard']);
+
+ view.rerender(ui(metadata('en')));
+ expect(shownLabels()).toEqual(['Delivery Operations']);
+ });
+});
diff --git a/packages/app-shell/src/context/RecentItemsProvider.tsx b/packages/app-shell/src/context/RecentItemsProvider.tsx
index 2b86c8afca..2dedb2f431 100644
--- a/packages/app-shell/src/context/RecentItemsProvider.tsx
+++ b/packages/app-shell/src/context/RecentItemsProvider.tsx
@@ -10,6 +10,12 @@
* - Writes are debounced and pushed to the adapter; localStorage stays in
* sync as a cold-start cache.
* - Storage key is scoped by `user.id` to avoid cross-account leakage.
+ * - An object, dashboard, page or report entry is stored by IDENTITY — its
+ * `type` and `name` — never by display text; its label is resolved when it is
+ * rendered (`useRecentItemLabel`), in the language of that render
+ * (objectui#11678).
+ * - A list equal to the one already persisted is not written again: a visit
+ * that does not change the list writes nothing, whoever reported the visit.
*
* The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is
* preserved via a re-export shim so existing call sites keep working.
@@ -39,19 +45,57 @@ import {
// Types
// ---------------------------------------------------------------------------
-export interface RecentItem {
+/**
+ * Every kind of recent entry. Spelled as one literal union because the
+ * `home.recentApps.itemType.*` key family is checked against exactly these
+ * members (`pnpm check:i18n-keys`); the two entry shapes below take their arms
+ * from it.
+ */
+export type RecentItemType = 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'metadata';
+
+interface RecentItemBase {
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
id: string;
- label: string;
href: string;
- type: 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'metadata';
- /** ISO timestamp of last visit */
+ /** ISO timestamp of the visit that made this entry the most recent one */
visitedAt: string;
}
+/**
+ * An object, dashboard, page or report — stored by its identity, `type` and
+ * `name`, and by no display text (objectui#11678).
+ *
+ * Its label is resolved every time it is rendered, from the item's own
+ * metadata in the current language (`useRecentItemLabel`). A label stored at
+ * visit time is frozen in the language and the spelling of that moment, and
+ * the route the tracker reads offers nothing to make one from but the machine
+ * name — which is how "Showcase Ops Dashboard" came to stand for a dashboard
+ * labelled "Delivery Operations".
+ */
+export interface RecentNamedItem extends RecentItemBase {
+ type: Exclude;
+ /** The item's machine name: the object's, dashboard's, page's or report's `name`. */
+ name: string;
+}
+
+/**
+ * A record, or a Studio metadata item. Neither has a metadata label to resolve
+ * when rendered, so the entry carries the text it was visited under: a record's
+ * display title (data read off the record), a metadata item's name.
+ */
+export interface RecentTextItem extends RecentItemBase {
+ type: Extract;
+ label: string;
+}
+
+export type RecentItem = RecentNamedItem | RecentTextItem;
+
+/** What `addRecentItem` takes: an entry without its visit time, each kind with its own members. */
+export type RecentItemInput = Omit | Omit;
+
interface RecentItemsContextValue {
recentItems: RecentItem[];
- addRecentItem: (item: Omit) => void;
+ addRecentItem: (item: RecentItemInput) => void;
clearRecentItems: () => void;
}
@@ -62,10 +106,76 @@ interface RecentItemsContextValue {
const STORAGE_BASE_KEY = 'objectui-recent-items';
const MAX_RECENT = 8;
+const NAMED_TYPES: ReadonlySet = new Set(['object', 'dashboard', 'page', 'report']);
+const TEXT_TYPES: ReadonlySet = new Set(['record', 'metadata']);
+
+/**
+ * One stored entry in the shape this provider keeps, or `null` when it is not
+ * an entry.
+ *
+ * Every list this provider holds comes through here — localStorage, the
+ * adapter's `load()`, another tab's write, and `addRecentItem`'s argument — so
+ * the persisted shape has one reader, and what is written back is always the
+ * identity shape.
+ *
+ * ⚠️ The lists already in users' `ui.recent` rows and localStorage predate that
+ * shape: an object, dashboard, page or report entry there carries a `label`
+ * minted at visit time and no `name` (objectui#11678). That label is NOT read.
+ * The identity was always in `id` — `:`, the only id the console's
+ * tracker ever wrote for those four kinds — so the name is read from there and
+ * the minted text is dropped, which puts the item's current label in its place
+ * on the next render. An entry of those kinds whose `id` carries no `:`
+ * prefix names nothing that can be resolved, and is dropped.
+ */
+function readEntry(raw: unknown): RecentItem | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const entry = raw as Record;
+ const { id, type, href } = entry;
+ if (typeof id !== 'string' || typeof type !== 'string' || typeof href !== 'string') return null;
+ const visitedAt = typeof entry.visitedAt === 'string' ? entry.visitedAt : '';
+ if (NAMED_TYPES.has(type)) {
+ const prefix = `${type}:`;
+ const name =
+ typeof entry.name === 'string'
+ ? entry.name
+ : id.startsWith(prefix)
+ ? id.slice(prefix.length)
+ : '';
+ if (name === '') return null;
+ return { id, type: type as RecentNamedItem['type'], name, href, visitedAt };
+ }
+ if (TEXT_TYPES.has(type) && typeof entry.label === 'string') {
+ return { id, type: type as RecentTextItem['type'], label: entry.label, href, visitedAt };
+ }
+ return null;
+}
+
+/** A stored list, read entry by entry through {@link readEntry} and capped. */
+function readList(raw: unknown): RecentItem[] {
+ if (!Array.isArray(raw)) return [];
+ const items: RecentItem[] = [];
+ for (const value of raw) {
+ const item = readEntry(value);
+ if (item) items.push(item);
+ }
+ return items.slice(0, MAX_RECENT);
+}
+
+/** What makes an entry this entry — everything but its visit time. */
+function contentOf(item: RecentItem): string {
+ switch (item.type) {
+ case 'record':
+ case 'metadata':
+ return JSON.stringify([item.id, item.type, item.href, item.label]);
+ default:
+ return JSON.stringify([item.id, item.type, item.href, item.name]);
+ }
+}
+
function loadRecent(userId?: string | null): RecentItem[] {
try {
const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
- return raw ? JSON.parse(raw) : [];
+ return raw ? readList(JSON.parse(raw)) : [];
} catch {
return [];
}
@@ -96,17 +206,26 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) {
const [recentItems, setRecentItems] = useState(() => loadRecent(userId));
+ // The list as last installed — always the one the persisted copies hold,
+ // since every list arrives either FROM storage (`adopt`) or is written TO it
+ // (`commit`). Decisions read it outside any state updater: an updater may run
+ // more than once for one update (React StrictMode does it on purpose), and a
+ // write made inside one runs again with it.
+ const itemsRef = useRef(recentItems);
+
+ /** Install a list that was just read from storage, so it is already persisted. */
+ const adopt = useCallback((items: RecentItem[]) => {
+ itemsRef.current = items;
+ setRecentItems(items);
+ }, []);
+
useEffect(() => {
- setRecentItems(loadRecent(userId));
- }, [userId]);
+ adopt(loadRecent(userId));
+ }, [userId, adopt]);
// Cross-tab sync — see FavoritesProvider for the rationale.
useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
- setRecentItems(
- Array.isArray(value)
- ? value.filter(it => it && typeof (it as any).id === 'string').slice(0, MAX_RECENT)
- : [],
- );
+ adopt(readList(value));
});
const hydrationToken = useRef(0);
@@ -118,10 +237,8 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) {
try {
const remote = await adapter.load();
if (cancelled || token !== hydrationToken.current) return;
- const sane = (Array.isArray(remote) ? remote : [])
- .filter(it => it && typeof (it as any).id === 'string')
- .slice(0, MAX_RECENT);
- setRecentItems(sane);
+ const sane = readList(remote);
+ adopt(sane);
saveRecent(sane, userId);
} catch {
// ignore — degrade to localStorage
@@ -130,7 +247,7 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
};
- }, [adapter, userId]);
+ }, [adapter, userId, adopt]);
const flusher = useMemo(
() => createDebouncedFlush(async items => {
@@ -140,8 +257,15 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) {
);
useEffect(() => () => { void flusher.flush(); }, [flusher]);
+ // The store's own guard (objectui#11678): whoever asks for the write, a list
+ // equal to the one already held — and so already persisted — changes no
+ // state, writes no localStorage and calls no `adapter.save()`: no
+ // `sys_user_preference` PATCH, and so no audit row for one.
const commit = useCallback(
(next: RecentItem[]) => {
+ if (JSON.stringify(next) === JSON.stringify(itemsRef.current)) return;
+ itemsRef.current = next;
+ setRecentItems(next);
saveRecent(next, userId);
if (adapter) flusher.schedule(next);
},
@@ -149,22 +273,21 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) {
);
const addRecentItem = useCallback(
- (item: Omit) => {
- setRecentItems(prev => {
- const filtered = prev.filter(r => r.id !== item.id);
- const updated = [
- { ...item, visitedAt: new Date().toISOString() },
- ...filtered,
- ].slice(0, MAX_RECENT);
- commit(updated);
- return updated;
- });
+ (input: RecentItemInput) => {
+ const item = readEntry({ ...input, visitedAt: new Date().toISOString() });
+ if (!item) return;
+ const prev = itemsRef.current;
+ // Revisiting the entry already at the head changes nothing, so it records
+ // nothing — not even a fresher `visitedAt`, which would be a write
+ // carrying no information. The head's `visitedAt` is when it BECAME the
+ // most recent entry.
+ if (prev.length > 0 && contentOf(prev[0]) === contentOf(item)) return;
+ commit([item, ...prev.filter(r => r.id !== item.id)].slice(0, MAX_RECENT));
},
[commit],
);
const clearRecentItems = useCallback(() => {
- setRecentItems([]);
commit([]);
}, [commit]);
diff --git a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx
index 2ddcc14186..cc82769f45 100644
--- a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx
+++ b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx
@@ -58,7 +58,7 @@ describe('RecentItemsProvider', () => {
it('hydrates synchronously from user-scoped localStorage on mount', () => {
const seeded: RecentItem[] = [
- { id: 'object:c', label: 'C', href: '/c', type: 'object', visitedAt: '2024-01-01' },
+ { id: 'object:c', type: 'object', name: 'c', href: '/c', visitedAt: '2024-01-01' },
];
localStorage.setItem('objectui-recent-items:u:user-1', JSON.stringify(seeded));
@@ -69,12 +69,12 @@ describe('RecentItemsProvider', () => {
it('addRecentItem prepends and updates timestamp on revisit', () => {
const { result } = renderHook(() => useRecentItems(), { wrapper });
- act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' }));
- act(() => result.current.addRecentItem({ id: 'b', label: 'B', href: '/b', type: 'object' }));
- act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' }));
+ act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' }));
+ act(() => result.current.addRecentItem({ id: 'object:b', type: 'object', name: 'b', href: '/b' }));
+ act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' }));
// 'a' moves back to the front; no duplicate.
- expect(result.current.recentItems.map(r => r.id)).toEqual(['a', 'b']);
+ expect(result.current.recentItems.map(r => r.id)).toEqual(['object:a', 'object:b']);
});
it('caps recent items at 8', () => {
@@ -83,48 +83,48 @@ describe('RecentItemsProvider', () => {
act(() => {
for (let i = 0; i < 12; i++) {
result.current.addRecentItem({
- id: `r-${i}`,
- label: `R ${i}`,
- href: `/r/${i}`,
+ id: `object:r-${i}`,
type: 'object',
+ name: `r-${i}`,
+ href: `/r/${i}`,
});
}
});
expect(result.current.recentItems).toHaveLength(8);
// Most recent first.
- expect(result.current.recentItems[0].id).toBe('r-11');
+ expect(result.current.recentItems[0].id).toBe('object:r-11');
});
it('persists to user-scoped localStorage only', () => {
const { result } = renderHook(() => useRecentItems(), { wrapper });
act(() =>
- result.current.addRecentItem({ id: 'x', label: 'X', href: '/x', type: 'page' }),
+ result.current.addRecentItem({ id: 'page:x', type: 'page', name: 'x', href: '/x' }),
);
expect(localStorage.getItem('objectui-recent-items')).toBeNull();
const stored = JSON.parse(localStorage.getItem('objectui-recent-items:u:user-1') || '[]');
- expect(stored[0]).toMatchObject({ id: 'x', type: 'page' });
+ expect(stored[0]).toMatchObject({ id: 'page:x', type: 'page', name: 'x' });
});
it('hydrates from adapter and overrides localStorage state', async () => {
localStorage.setItem(
'objectui-recent-items:u:user-1',
- JSON.stringify([{ id: 'local', label: 'L', href: '/l', type: 'object', visitedAt: 't' }]),
+ JSON.stringify([{ id: 'object:local', type: 'object', name: 'local', href: '/l', visitedAt: 't' }]),
);
const remote: RecentItem[] = [
- { id: 'remote', label: 'R', href: '/r', type: 'dashboard', visitedAt: 't' },
+ { id: 'dashboard:remote', type: 'dashboard', name: 'remote', href: '/r', visitedAt: 't' },
];
const adapter = makeAdapter(remote);
const { result } = renderHook(() => useHarness(), { wrapper });
- expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['local']);
+ expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['object:local']);
act(() => result.current.attach('recent', adapter));
await waitFor(() => {
- expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['remote']);
+ expect(result.current.recent.recentItems.map(r => r.id)).toEqual(['dashboard:remote']);
});
expect(JSON.parse(localStorage.getItem('objectui-recent-items:u:user-1') || '[]')).toEqual(remote);
});
@@ -138,22 +138,22 @@ describe('RecentItemsProvider', () => {
adapter.saveMock.mockClear();
act(() => {
- result.current.recent.addRecentItem({ id: '1', label: '1', href: '/1', type: 'object' });
- result.current.recent.addRecentItem({ id: '2', label: '2', href: '/2', type: 'object' });
- result.current.recent.addRecentItem({ id: '3', label: '3', href: '/3', type: 'object' });
+ result.current.recent.addRecentItem({ id: 'object:1', type: 'object', name: '1', href: '/1' });
+ result.current.recent.addRecentItem({ id: 'object:2', type: 'object', name: '2', href: '/2' });
+ result.current.recent.addRecentItem({ id: 'object:3', type: 'object', name: '3', href: '/3' });
});
expect(adapter.saveMock).not.toHaveBeenCalled();
await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 });
const persisted = adapter.saveMock.mock.calls[0][0] as RecentItem[];
- expect(persisted.map(p => p.id).sort()).toEqual(['1', '2', '3']);
+ expect(persisted.map(p => p.id).sort()).toEqual(['object:1', 'object:2', 'object:3']);
});
it('clearRecentItems empties the list and persists []', () => {
const { result } = renderHook(() => useRecentItems(), { wrapper });
- act(() => result.current.addRecentItem({ id: 'a', label: 'A', href: '/a', type: 'object' }));
+ act(() => result.current.addRecentItem({ id: 'object:a', type: 'object', name: 'a', href: '/a' }));
act(() => result.current.clearRecentItems());
expect(result.current.recentItems).toEqual([]);
@@ -164,7 +164,206 @@ describe('RecentItemsProvider', () => {
const { result } = renderHook(() => useRecentItems());
expect(result.current.recentItems).toEqual([]);
expect(() =>
- result.current.addRecentItem({ id: 'x', label: 'X', href: '/x', type: 'object' }),
+ result.current.addRecentItem({ id: 'object:x', type: 'object', name: 'x', href: '/x' }),
).not.toThrow();
});
});
+
+/**
+ * objectui#11678 — "a visit that does not change the list writes nothing" is a
+ * property of the STORE, not of whoever reports the visit: the route tracker,
+ * a record page, a host's own call. Each write the provider makes is a
+ * localStorage write plus an `adapter.save()` — a `sys_user_preference` PATCH
+ * and an audit row — so these count both.
+ */
+describe('RecentItemsProvider — no change, no write (objectui#11678)', () => {
+ const KEY = 'objectui-recent-items:u:user-1';
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ /** An attached, hydrated adapter whose saves are counted from zero. */
+ async function mountWithAdapter() {
+ const adapter = makeAdapter([]);
+ const view = renderHook(() => useHarness(), { wrapper });
+ act(() => view.result.current.attach('recent', adapter));
+ await waitFor(() => expect(adapter.loadMock).toHaveBeenCalled());
+ await act(async () => {
+ await Promise.resolve();
+ });
+ adapter.saveMock.mockClear();
+ return { ...view, adapter };
+ }
+
+ /** Every localStorage write to the recent-items key, counted. */
+ function countStorageWrites() {
+ const spy = vi.spyOn(Storage.prototype, 'setItem');
+ return {
+ count: () => spy.mock.calls.filter(([key]) => key === KEY).length,
+ restore: () => spy.mockRestore(),
+ };
+ }
+
+ it('re-adding the entry already at the head writes nothing — not even a fresher visitedAt', async () => {
+ const { result, adapter } = await mountWithAdapter();
+ act(() =>
+ result.current.recent.addRecentItem({ id: 'dashboard:ops', type: 'dashboard', name: 'ops', href: '/d/ops' }),
+ );
+ await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 });
+ const visitedAt = result.current.recent.recentItems[0].visitedAt;
+
+ const writes = countStorageWrites();
+ try {
+ act(() =>
+ result.current.recent.addRecentItem({ id: 'dashboard:ops', type: 'dashboard', name: 'ops', href: '/d/ops' }),
+ );
+ await new Promise(r => setTimeout(r, 700));
+ expect(writes.count()).toBe(0);
+ } finally {
+ writes.restore();
+ }
+ expect(adapter.saveMock).toHaveBeenCalledTimes(1);
+ expect(result.current.recent.recentItems[0].visitedAt).toBe(visitedAt);
+ });
+
+ it('clearing an already-empty list writes nothing', async () => {
+ const { result, adapter } = await mountWithAdapter();
+ const writes = countStorageWrites();
+ try {
+ act(() => result.current.recent.clearRecentItems());
+ await new Promise(r => setTimeout(r, 700));
+ expect(writes.count()).toBe(0);
+ } finally {
+ writes.restore();
+ }
+ expect(adapter.saveMock).not.toHaveBeenCalled();
+ });
+
+ it('control: a head whose content changed (a record title that resolved) IS written', async () => {
+ const { result, adapter } = await mountWithAdapter();
+ act(() =>
+ result.current.recent.addRecentItem({ id: 'record:acct:1', type: 'record', label: 'acct-1', href: '/r/1' }),
+ );
+ await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 });
+ act(() =>
+ result.current.recent.addRecentItem({ id: 'record:acct:1', type: 'record', label: 'Acme Corp', href: '/r/1' }),
+ );
+ await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(2), { timeout: 1500 });
+ expect(result.current.recent.recentItems).toEqual([
+ { id: 'record:acct:1', type: 'record', label: 'Acme Corp', href: '/r/1', visitedAt: expect.any(String) },
+ ]);
+ });
+});
+
+/**
+ * objectui#11678 — the lists already in users' `ui.recent` rows carry the old
+ * shape: an object / dashboard / page / report entry with a `label` minted at
+ * visit time ("Showcase Ops Dashboard") and no `name`. The reader keeps the
+ * entry and drops the minted text: the identity was always in `id`
+ * (`:`), so the item's current label replaces it on render.
+ */
+describe('RecentItemsProvider — reading a list stored before the identity shape (objectui#11678)', () => {
+ const LEGACY = [
+ {
+ id: 'dashboard:showcase_ops_dashboard',
+ label: 'Showcase Ops Dashboard',
+ href: '/apps/showcase_app/dashboard/showcase_ops_dashboard',
+ type: 'dashboard',
+ visitedAt: '2026-10-06T09:00:00.000Z',
+ },
+ {
+ id: 'object:showcase_task',
+ label: 'Task',
+ href: '/apps/showcase_app/showcase_task',
+ type: 'object',
+ visitedAt: '2026-10-06T08:59:00.000Z',
+ },
+ // A record and a Studio metadata item keep their text: neither has a
+ // metadata label to resolve.
+ {
+ id: 'record:showcase_task:t1',
+ label: 'Write the brief',
+ href: '/apps/showcase_app/showcase_task/record/t1',
+ type: 'record',
+ visitedAt: '2026-10-06T08:58:00.000Z',
+ },
+ {
+ id: 'metadata:object:sys_user',
+ label: 'sys_user',
+ href: '/apps/setup/metadata/object/sys_user',
+ type: 'metadata',
+ visitedAt: '2026-10-06T08:57:00.000Z',
+ },
+ // A host's own write whose id carries no `:` names nothing to resolve.
+ { id: 'my-page', label: 'Mine', href: '/x', type: 'page', visitedAt: '2026-10-06T08:56:00.000Z' },
+ ];
+ const READ: RecentItem[] = [
+ {
+ id: 'dashboard:showcase_ops_dashboard',
+ type: 'dashboard',
+ name: 'showcase_ops_dashboard',
+ href: '/apps/showcase_app/dashboard/showcase_ops_dashboard',
+ visitedAt: '2026-10-06T09:00:00.000Z',
+ },
+ {
+ id: 'object:showcase_task',
+ type: 'object',
+ name: 'showcase_task',
+ href: '/apps/showcase_app/showcase_task',
+ visitedAt: '2026-10-06T08:59:00.000Z',
+ },
+ {
+ id: 'record:showcase_task:t1',
+ type: 'record',
+ label: 'Write the brief',
+ href: '/apps/showcase_app/showcase_task/record/t1',
+ visitedAt: '2026-10-06T08:58:00.000Z',
+ },
+ {
+ id: 'metadata:object:sys_user',
+ type: 'metadata',
+ label: 'sys_user',
+ href: '/apps/setup/metadata/object/sys_user',
+ visitedAt: '2026-10-06T08:57:00.000Z',
+ },
+ ];
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('from localStorage: the identity is read out of the id, the minted label is dropped', () => {
+ localStorage.setItem('objectui-recent-items:u:user-1', JSON.stringify(LEGACY));
+ const { result } = renderHook(() => useRecentItems(), { wrapper });
+ expect(result.current.recentItems).toEqual(READ);
+ });
+
+ it('from the adapter: the same read, and reading it writes nothing back', async () => {
+ const adapter = makeAdapter(LEGACY as unknown as RecentItem[]);
+ const { result } = renderHook(() => useHarness(), { wrapper });
+ act(() => result.current.attach('recent', adapter));
+ await waitFor(() => expect(result.current.recent.recentItems).toEqual(READ));
+ await new Promise(r => setTimeout(r, 700));
+ expect(adapter.saveMock).not.toHaveBeenCalled();
+ });
+
+ it('the next change writes the identity shape, with no legacy label left in it', async () => {
+ const adapter = makeAdapter(LEGACY as unknown as RecentItem[]);
+ const { result } = renderHook(() => useHarness(), { wrapper });
+ act(() => result.current.attach('recent', adapter));
+ await waitFor(() => expect(result.current.recent.recentItems).toEqual(READ));
+ act(() =>
+ result.current.recent.addRecentItem({
+ id: 'page:showcase_capability_map',
+ type: 'page',
+ name: 'showcase_capability_map',
+ href: '/apps/showcase_app/page/showcase_capability_map',
+ }),
+ );
+ await waitFor(() => expect(adapter.saveMock).toHaveBeenCalledTimes(1), { timeout: 1500 });
+ const saved = adapter.saveMock.mock.calls[0][0] as RecentItem[];
+ expect(saved.slice(1)).toEqual(READ);
+ expect(JSON.stringify(saved)).not.toContain('Showcase Ops Dashboard');
+ });
+});
diff --git a/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx
new file mode 100644
index 0000000000..d316443c73
--- /dev/null
+++ b/packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * 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.
+ */
+
+/**
+ * `useRecentItemLabel` — each kind of recent entry is labelled by the resolver
+ * the console already has for that kind, and nothing is minted (objectui#11678).
+ *
+ * The dashboard / page / zh-CN roads are pinned through the real Home cards in
+ * `console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx`; this file
+ * covers the kinds Home does not list (a report) and the two kinds that carry
+ * their own text.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { ReactNode } from 'react';
+import { renderHook } from '@testing-library/react';
+import { MetadataCtx } from '@object-ui/react';
+import { createI18n, I18nProvider } from '@object-ui/i18n';
+import { useRecentItemLabel } from '../useRecentItemLabel';
+import type { RecentItem } from '../../context/RecentItemsProvider';
+
+function metadata() {
+ return {
+ apps: [],
+ objects: [{ name: 'showcase_task', label: 'Tasks' }],
+ dashboards: [],
+ reports: [{ name: 'q3_results', label: 'Q3 Results' }],
+ pages: [],
+ loading: false,
+ error: null,
+ refresh: async () => {},
+ invalidate: () => {},
+ ensureType: async () => [],
+ getItem: async () => null,
+ getItemsByType: () => [],
+ getTypeStatus: () => 'ready' as const,
+ };
+}
+
+function wrapper(language: string) {
+ const i18n = createI18n({
+ defaultLanguage: language,
+ detectBrowserLanguage: false,
+ resources: { 'zh-CN': { showcase: { reports: { q3_results: { label: '第三季度结果' } } } } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+}
+
+const at = '2026-10-06T09:00:00.000Z';
+const REPORT: RecentItem = { id: 'report:q3_results', type: 'report', name: 'q3_results', href: '/r', visitedAt: at };
+
+describe('useRecentItemLabel (objectui#11678)', () => {
+ it('a report: its metadata label in en, its bundle label in zh-CN', () => {
+ const en = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') });
+ expect(en.result.current(REPORT)).toBe('Q3 Results');
+ const zh = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN') });
+ expect(zh.result.current(REPORT)).toBe('第三季度结果');
+ });
+
+ it('an object: its current metadata label', () => {
+ const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') });
+ expect(
+ result.current({ id: 'object:showcase_task', type: 'object', name: 'showcase_task', href: '/o', visitedAt: at }),
+ ).toBe('Tasks');
+ });
+
+ it('an item with no metadata label shows its machine name — never a titleized one', () => {
+ const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('en') });
+ expect(
+ result.current({ id: 'page:welcome_tour', type: 'page', name: 'welcome_tour', href: '/p', visitedAt: at }),
+ ).toBe('welcome_tour');
+ });
+
+ it('a record and a Studio metadata item show the text they carry', () => {
+ const { result } = renderHook(() => useRecentItemLabel(), { wrapper: wrapper('zh-CN') });
+ expect(
+ result.current({ id: 'record:showcase_task:t1', type: 'record', label: 'Write the brief', href: '/x', visitedAt: at }),
+ ).toBe('Write the brief');
+ expect(
+ result.current({ id: 'metadata:object:sys_user', type: 'metadata', label: 'sys_user', href: '/m', visitedAt: at }),
+ ).toBe('sys_user');
+ });
+});
diff --git a/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx b/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx
index d1047e1095..c0d221d789 100644
--- a/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx
+++ b/packages/app-shell/src/hooks/__tests__/useTrackRouteAsRecent.test.tsx
@@ -6,9 +6,9 @@
* LICENSE file in the root directory of this source tree.
*/
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import type { ReactNode } from 'react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { act, render, renderHook } from '@testing-library/react';
+import { useEffect, type ReactNode } from 'react';
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal>()),
@@ -21,7 +21,11 @@ import {
useRecentItems,
type RecentItem,
} from '../../context/RecentItemsProvider';
-import { UserStateAdaptersProvider } from '../../context/UserStateAdapters';
+import {
+ UserStateAdaptersProvider,
+ useAttachUserStateAdapters,
+ type UserDataAdapter,
+} from '../../context/UserStateAdapters';
function wrapper({ children }: { children: ReactNode }) {
return (
@@ -47,46 +51,66 @@ describe('useTrackRouteAsRecent', () => {
localStorage.clear();
});
- it('records an object route', () => {
+ // objectui#11678 — an object, dashboard, page or report is recorded by its
+ // identity (`type` + `name`) and with NO label: the route has nothing but the
+ // machine name to make one from, and the label is resolved where the entry
+ // is rendered (`useRecentItemLabel`). `toEqual`, not `toMatchObject`, so a
+ // `label` coming back fails the pin.
+ it('records an object route by identity, with no label', () => {
const { result } = renderHook(
() =>
useHarness('/apps/sales/contact', 'sales', [{ name: 'contact', label: 'Contacts' }]),
{ wrapper },
);
- expect(result.current[0]).toMatchObject({
+ expect(result.current[0]).toEqual({
id: 'object:contact',
- label: 'Contacts',
- href: '/apps/sales/contact',
type: 'object',
+ name: 'contact',
+ href: '/apps/sales/contact',
+ visitedAt: expect.any(String),
});
});
- it('records a dashboard route', () => {
+ it('records a dashboard route by identity — no label minted from the slug', () => {
const { result } = renderHook(
() => useHarness('/apps/sales/dashboard/sales_overview', 'sales'),
{ wrapper },
);
- expect(result.current[0]).toMatchObject({
+ expect(result.current[0]).toEqual({
id: 'dashboard:sales_overview',
- label: 'Sales Overview',
type: 'dashboard',
+ name: 'sales_overview',
+ href: '/apps/sales/dashboard/sales_overview',
+ visitedAt: expect.any(String),
});
});
- it('records a page route', () => {
+ it('records a page route by identity', () => {
const { result } = renderHook(
() => useHarness('/apps/cs/page/welcome-tour', 'cs'),
{ wrapper },
);
- expect(result.current[0]).toMatchObject({ id: 'page:welcome-tour', type: 'page' });
+ expect(result.current[0]).toEqual({
+ id: 'page:welcome-tour',
+ type: 'page',
+ name: 'welcome-tour',
+ href: '/apps/cs/page/welcome-tour',
+ visitedAt: expect.any(String),
+ });
});
- it('records a report route', () => {
+ it('records a report route by identity', () => {
const { result } = renderHook(
() => useHarness('/apps/sales/report/q3-results', 'sales'),
{ wrapper },
);
- expect(result.current[0]).toMatchObject({ id: 'report:q3-results', type: 'report' });
+ expect(result.current[0]).toEqual({
+ id: 'report:q3-results',
+ type: 'report',
+ name: 'q3-results',
+ href: '/apps/sales/report/q3-results',
+ visitedAt: expect.any(String),
+ });
});
it('records a Studio metadata item route', () => {
@@ -190,3 +214,105 @@ describe('useTrackRouteAsRecent', () => {
expect(ids).toEqual(['object:order', 'object:contact']);
});
});
+
+/**
+ * objectui#11678 — "revisiting the most recent item makes no write".
+ *
+ * Measured before the fix (the real provider and this hook over a counting
+ * adapter): coming back to the entry already at the head of the list, and
+ * moving from an object's list route to one of its view routes, each saved the
+ * list a second time — identical but for a fresher `visitedAt` — and every
+ * save is a `sys_user_preference` PATCH. These drive the hook through real
+ * route changes and count `adapter.save()` calls; the provider's own pins are
+ * in `RecentItemsProvider.test.tsx`.
+ */
+describe('useTrackRouteAsRecent — a visit that does not change the list writes nothing (objectui#11678)', () => {
+ const OBJECTS = [{ name: 'showcase_task', label: 'Task' }];
+
+ function makeAdapter() {
+ const save = vi.fn().mockResolvedValue(undefined);
+ const adapter: UserDataAdapter = { load: vi.fn().mockResolvedValue([]), save };
+ return { adapter, save };
+ }
+
+ function Visit({ pathname, adapter }: { pathname: string; adapter: UserDataAdapter }) {
+ const attach = useAttachUserStateAdapters();
+ useEffect(() => {
+ attach('recent', adapter);
+ return () => attach('recent', null);
+ }, [attach, adapter]);
+ useTrackRouteAsRecent({ pathname, appName: 'showcase_app', objects: OBJECTS });
+ return null;
+ }
+
+ /** Let hydration settle and the 500ms write debounce run out. */
+ async function settle() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(700);
+ });
+ }
+
+ /**
+ * Mount on a route the hook ignores, so the adapter is attached and hydrated
+ * before the first tracked visit (a visit landing before hydration resolves
+ * is overwritten by it — a separate race this file does not pin).
+ */
+ async function mount(adapter: UserDataAdapter) {
+ const ui = (pathname: string) => wrapper({ children: });
+ const view = render(ui('/home'));
+ await settle();
+ return (pathname: string) => view.rerender(ui(pathname));
+ }
+
+ beforeEach(() => {
+ localStorage.clear();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('revisiting the most recent item makes no write', async () => {
+ const { adapter, save } = makeAdapter();
+ const go = await mount(adapter);
+
+ go('/apps/showcase_app/dashboard/showcase_ops_dashboard');
+ await settle();
+ expect(save).toHaveBeenCalledTimes(1);
+
+ go('/home');
+ await settle();
+ go('/apps/showcase_app/dashboard/showcase_ops_dashboard');
+ await settle();
+ expect(save).toHaveBeenCalledTimes(1);
+ });
+
+ it('an object’s list route and its view route are one visit, saved once', async () => {
+ const { adapter, save } = makeAdapter();
+ const go = await mount(adapter);
+
+ go('/apps/showcase_app/showcase_task');
+ await settle();
+ go('/apps/showcase_app/showcase_task/view/all_tasks');
+ await settle();
+ expect(save).toHaveBeenCalledTimes(1);
+ });
+
+ it('control: a visit that DOES change the list is saved', async () => {
+ const { adapter, save } = makeAdapter();
+ const go = await mount(adapter);
+
+ go('/apps/showcase_app/dashboard/showcase_ops_dashboard');
+ await settle();
+ go('/apps/showcase_app/page/showcase_task_schedule');
+ await settle();
+ go('/apps/showcase_app/dashboard/showcase_ops_dashboard');
+ await settle();
+ expect(save).toHaveBeenCalledTimes(3);
+ const last = save.mock.calls[2][0] as RecentItem[];
+ expect(last.map(r => r.id)).toEqual([
+ 'dashboard:showcase_ops_dashboard',
+ 'page:showcase_task_schedule',
+ ]);
+ });
+});
diff --git a/packages/app-shell/src/hooks/useNavTargetLabel.ts b/packages/app-shell/src/hooks/useNavTargetLabel.ts
index 8fb687bee0..c65f5f3004 100644
--- a/packages/app-shell/src/hooks/useNavTargetLabel.ts
+++ b/packages/app-shell/src/hooks/useNavTargetLabel.ts
@@ -53,8 +53,12 @@ import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout';
import { useMetadata } from '../providers/MetadataProvider.js';
-/** A metadata label (plain string or inline locale map) as display text, or `undefined` when there is none. */
-function metadataText(label: unknown, language: string | undefined): string | undefined {
+/**
+ * A metadata label (plain string or inline locale map) as display text, or
+ * `undefined` when there is none. Shared with `useRecentItemLabel`, which asks
+ * the same question of a page or a report (objectui#11678).
+ */
+export function metadataText(label: unknown, language: string | undefined): string | undefined {
const text = resolveInlineI18nLabel(label as Parameters[0], language);
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
}
@@ -65,13 +69,14 @@ function nonEmpty(text: string | undefined): string | undefined {
}
/** The two members this hook reads off a cached metadata record. */
-interface LabelledRecord {
+export interface LabelledRecord {
name?: unknown;
label?: unknown;
listViews?: unknown;
}
-function findByName(items: readonly unknown[] | undefined, name: string): LabelledRecord | undefined {
+/** The cached metadata record named `name`, or `undefined`. Shared with `useRecentItemLabel`. */
+export function findByName(items: readonly unknown[] | undefined, name: string): LabelledRecord | undefined {
return (items ?? []).find(
(item): item is LabelledRecord =>
!!item && typeof item === 'object' && (item as LabelledRecord).name === name,
diff --git a/packages/app-shell/src/hooks/useRecentItemLabel.ts b/packages/app-shell/src/hooks/useRecentItemLabel.ts
new file mode 100644
index 0000000000..7e7289ba81
--- /dev/null
+++ b/packages/app-shell/src/hooks/useRecentItemLabel.ts
@@ -0,0 +1,80 @@
+/**
+ * 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.
+ */
+
+/**
+ * useRecentItemLabel — what a "recently accessed" entry is called NOW, in the
+ * current language (objectui#11678).
+ *
+ * An object, dashboard, page or report entry stores its identity only
+ * (`RecentNamedItem`: `type` + `name`), so this hook is where its text comes
+ * from — on every render, never stored. A rename, or a switch of language,
+ * shows on the next render; nothing is minted from the route.
+ *
+ * It holds no labelling rule of its own. Each kind asks what the console
+ * already asks for that kind:
+ *
+ * - an object or a dashboard: {@link useNavTargetLabel} — the text the
+ * sidebar shows for an unlabelled navigation entry that opens the same
+ * target (the metadata label, an inline locale map read in the active
+ * language, through the `useObjectLabel` bundle lookup);
+ * - a page or a report: the same pair for that kind — the page's / report's
+ * cached metadata `label`, through `useObjectLabel`'s `pageLabel` /
+ * `reportLabel`. `useNavTargetLabel` has no page or report target because
+ * the navigation ruling names none, so the pair is composed here from the
+ * same two helpers it uses.
+ *
+ * With no label anywhere the entry shows its machine name — the backstop the
+ * navigation renderer uses too, including before the type's metadata has
+ * loaded.
+ *
+ * A record or a Studio metadata item (`RecentTextItem`) carries its own text,
+ * which is returned as stored.
+ */
+
+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 { findByName, metadataText, useNavTargetLabel } from './useNavTargetLabel.js';
+
+/** Answers the display text of a recent entry, in the current language. */
+export type RecentItemLabelResolver = (item: RecentItem) => string;
+
+/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */
+function nonEmpty(text: string | undefined): string | undefined {
+ return typeof text === 'string' && text.trim() !== '' ? text : undefined;
+}
+
+export function useRecentItemLabel(): RecentItemLabelResolver {
+ const targetLabel = useNavTargetLabel();
+ const metadata = useMetadata();
+ const { pageLabel, reportLabel } = useObjectLabel();
+ const { language } = useObjectTranslation();
+
+ return useMemo(
+ () => (item: RecentItem) => {
+ switch (item.type) {
+ case 'object':
+ return targetLabel({ kind: 'object', objectName: item.name }) ?? item.name;
+ case 'dashboard':
+ return targetLabel({ kind: 'dashboard', dashboardName: item.name }) ?? item.name;
+ case 'page': {
+ const text = metadataText(findByName(metadata.pages, item.name)?.label, language);
+ return nonEmpty(pageLabel({ name: item.name, label: text ?? '' })) ?? item.name;
+ }
+ case 'report': {
+ const text = metadataText(findByName(metadata.reports, item.name)?.label, language);
+ return nonEmpty(reportLabel({ name: item.name, label: text ?? '' })) ?? item.name;
+ }
+ default:
+ return item.label;
+ }
+ },
+ [targetLabel, metadata, pageLabel, reportLabel, language],
+ );
+}
diff --git a/packages/app-shell/src/hooks/useRecentItems.ts b/packages/app-shell/src/hooks/useRecentItems.ts
index 57947fe003..15998114f6 100644
--- a/packages/app-shell/src/hooks/useRecentItems.ts
+++ b/packages/app-shell/src/hooks/useRecentItems.ts
@@ -11,4 +11,15 @@
* @module
*/
-export { useRecentItems, type RecentItem } from '../context/RecentItemsProvider.js';
+export {
+ useRecentItems,
+ type RecentItem,
+ type RecentItemInput,
+ type RecentItemType,
+ type RecentNamedItem,
+ type RecentTextItem,
+} from '../context/RecentItemsProvider.js';
+// ⛔ Not `useRecentItemLabel`: it reads the metadata cache, so re-exporting it
+// here would make every module that only wants the list — `RecordDetailView`
+// among them — load `MetadataProvider` and its metadata client at module load
+// (objectui#11678). The entry exports it from its own module.
diff --git a/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts b/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts
index a1208ab7e1..ebe80a5a9b 100644
--- a/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts
+++ b/packages/app-shell/src/hooks/useTrackRouteAsRecent.ts
@@ -19,8 +19,16 @@
* /apps/:appName/metadata/:type/:name
* /apps/:appName/metadata/:type/:name/history
*
- * Pass `objects` (the list available in the current app) so we can resolve
- * a human-readable label for object routes.
+ * Pass `objects` (the list available in the current app) so only an object
+ * the app has is recorded.
+ *
+ * An object, dashboard, page or report is recorded by IDENTITY — its type and
+ * its `name` — and with no label (objectui#11678). The route holds nothing but
+ * the machine name, so a label made here could only be that name dressed up
+ * (`titleize` used to turn `showcase_ops_dashboard` into "Showcase Ops
+ * Dashboard" for a dashboard labelled "Delivery Operations"), frozen in the
+ * visit's language. The label is resolved where the entry is rendered, from the
+ * item's metadata in the current language: `useRecentItemLabel`.
*
* @module
*/
@@ -29,14 +37,14 @@ import { useRecentItems } from '../context/RecentItemsProvider.js';
import type { ObjectLike as SpecObjectLike } from '@objectstack/spec/system';
/**
- * The two members this hook reads off an object in the current app, TAKEN from
+ * The two members this hook's callers hand it for an object, TAKEN from
* `@objectstack/spec/system`'s `ObjectLike` rather than restated under its name
* (objectui#7265).
*
* That export is the spec's own minimal projection of an object metadata
* document — the one `translateObject` consumes — and `name` / `label` mean
* exactly what they mean here: the object's identifier and its display label.
- * This hook needs no more than those two, so it PICKS them; the alternative,
+ * This hook takes no more than those two, so it PICKS them; the alternative,
* importing the whole shape, would drag in `fields` / `actions` / `pluralLabel`
* that no caller of this hook supplies and this hook never reads.
*
@@ -44,6 +52,10 @@ import type { ObjectLike as SpecObjectLike } from '@objectstack/spec/system';
* members wide, and it stops compiling the day the spec renames or retires
* either one — which a hand-written `{ name: string; label?: string }` never
* would. Pinned in `spec-symbol-parity.test.ts`.
+ *
+ * Since objectui#11678 the hook reads `name` only: an object is recorded by
+ * identity and labelled when rendered. `label` stays on the projection so the
+ * published options type does not move under its callers.
*/
type ObjectLike = Pick;
@@ -52,7 +64,7 @@ export interface UseTrackRouteAsRecentOptions {
pathname: string;
/** Currently selected app name. Used to build the `href` and namespace. */
appName: string | undefined;
- /** Objects available in the current app — used to resolve labels. */
+ /** Objects available in the current app — an object route is recorded only for one of these. */
objects?: ObjectLike[];
/** Optional override; defaults to `/apps`. */
basePathSegment?: string;
@@ -63,10 +75,6 @@ export interface UseTrackRouteAsRecentOptions {
/** Segments after `appName` that are NOT object names but route prefixes. */
const ROUTE_PREFIXES = new Set(['view', 'record', 'page', 'dashboard', 'design', 'report']);
-function titleize(slug: string): string {
- return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
-}
-
/** Decode a URL path segment, falling back to the raw value on malformed input. */
function safeDecode(value: string): string {
try {
@@ -128,9 +136,9 @@ export function useTrackRouteAsRecent({
if (obj) {
addRecentItem({
id: `object:${obj.name}`,
- label: obj.label || obj.name,
- href: `${basePath}/${obj.name}`,
type: 'object',
+ name: obj.name,
+ href: `${basePath}/${obj.name}`,
});
}
return;
@@ -140,29 +148,17 @@ export function useTrackRouteAsRecent({
switch (seg2) {
case 'dashboard':
- addRecentItem({
- id: `dashboard:${seg3}`,
- label: titleize(seg3),
- href: `${basePath}/dashboard/${seg3}`,
- type: 'dashboard',
- });
- break;
case 'page':
+ case 'report': {
+ const name = safeDecode(seg3);
addRecentItem({
- id: `page:${seg3}`,
- label: titleize(seg3),
- href: `${basePath}/page/${seg3}`,
- type: 'page',
- });
- break;
- case 'report':
- addRecentItem({
- id: `report:${seg3}`,
- label: titleize(seg3),
- href: `${basePath}/report/${seg3}`,
- type: 'report',
+ id: `${seg2}:${name}`,
+ type: seg2,
+ name,
+ href: `${basePath}/${seg2}/${seg3}`,
});
break;
+ }
default:
break;
}
diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts
index b98ed749a7..456fcfbd77 100644
--- a/packages/app-shell/src/index.ts
+++ b/packages/app-shell/src/index.ts
@@ -46,6 +46,9 @@ export {
type ObjectUiGlobal,
} from './observability/settleSignal.js';
export { useRecentItems } from './hooks/useRecentItems.js';
+// From its own module, not the `useRecentItems` shim: it reads the metadata
+// cache, and the shim must not drag that into a list-only importer's graph.
+export { useRecentItemLabel } from './hooks/useRecentItemLabel.js';
// Types
export type {
@@ -64,7 +67,12 @@ export type {
export type {
RecentItem,
+ RecentItemInput,
+ RecentItemType,
+ RecentNamedItem,
+ RecentTextItem,
} from './hooks/useRecentItems.js';
+export type { RecentItemLabelResolver } from './hooks/useRecentItemLabel.js';
// Console building blocks — compose these in your App.tsx to build the console
// routing tree. See examples/console-starter/src/App.tsx for a minimal example.
diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx
index de8ef08f83..9fbf38b65a 100644
--- a/packages/app-shell/src/layout/UnifiedSidebar.tsx
+++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx
@@ -45,6 +45,7 @@ import { useExpressionContext, evaluateVisibility } from '../providers/Expressio
import { usePermissions } from '@object-ui/permissions';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
import { useRecentItems } from '../hooks/useRecentItems.js';
+import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js';
import { useFavorites } from '../hooks/useFavorites.js';
import { useNavPins } from '../hooks/useNavPins.js';
import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js';
@@ -279,6 +280,9 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
}, [isMobile, setOpenMobile]);
const { recentItems } = useRecentItems();
+ // A recent entry's text is the item's own, resolved on this render
+ // (objectui#11678); the entry stores identity only.
+ const recentLabel = useRecentItemLabel();
const { favorites, removeFavorite } = useFavorites();
const { apps: metadataApps, objects: metadataObjects } = useMetadata();
@@ -691,12 +695,12 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
{recentItems.slice(0, 5).map(item => (
-
+
{item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'}
- {item.label}
+ {recentLabel(item)}
diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx
index b5c1830624..a83bec8a3e 100644
--- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.displayLocale-10232.test.tsx
@@ -45,11 +45,14 @@ vi.mock('../../context/RecentItemsProvider.js', async (importOriginal) => ({
...(await importOriginal>()),
useRecentItems: () => ({
recentItems: [
+ // A Studio metadata item — the entry kind that carries its own text, so
+ // the row reads 'Account' with no metadata cache behind it
+ // (objectui#11678 stores an object entry by identity only).
{
- id: 'object:account',
+ id: 'metadata:object:Account',
+ type: 'metadata',
label: 'Account',
- href: '/studio/object/account',
- type: 'object',
+ href: '/studio/metadata/object/Account',
visitedAt: new Date(Date.now() - THREE_DAYS).toISOString(),
},
],
diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
index 3ff2d079a9..a4c907bc60 100644
--- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
@@ -46,6 +46,7 @@ import {
import { Button } from '@object-ui/components';
import { useDisplayLocale } from '@object-ui/i18n';
import { useRecentItems, type RecentItem } from '../../context/RecentItemsProvider.js';
+import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js';
import {
useMetadataClient,
useMetadataTypes,
@@ -146,6 +147,8 @@ export function StudioHomePage() {
const client = useMetadataClient();
const { loading, entries } = useMetadataTypes(client);
const { recentItems } = useRecentItems();
+ // An entry's text is resolved on this render, never stored (objectui#11678).
+ const recentLabel = useRecentItemLabel();
const locale = useMetadataLocale();
// The visit times read the DISPLAY locale — not `locale` above, which picks
// this page's strings (objectui#10232).
@@ -428,7 +431,7 @@ export function StudioHomePage() {
- {item.label}
+ {recentLabel(item)}
{relativeTime(item.visitedAt, displayLocale)}
diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs
index c073c6eb66..357b3e38e7 100644
--- a/scripts/check-i18n-call-site-keys.mjs
+++ b/scripts/check-i18n-call-site-keys.mjs
@@ -1087,7 +1087,9 @@ export const DYNAMIC_KEY_FAMILIES = [
},
{
head: 'home.recentApps.itemType.',
- vocabulary: { module: 'packages/app-shell/src/context/RecentItemsProvider.tsx', name: 'RecentItem', kind: 'interfaceField', field: 'type' },
+ // `RecentItem` became a union of two entry shapes (objectui#11678); its
+ // `type` members are the one literal union both shapes draw their arms from.
+ vocabulary: { module: 'packages/app-shell/src/context/RecentItemsProvider.tsx', name: 'RecentItemType', kind: 'union' },
},
{
head: 'managedByBadge.',