From d98c1b4dcd768048506a45bb06309f6e155dbb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:37:49 +0000 Subject: [PATCH 1/4] fix(app-shell): recent items store identity and are labelled at render; an unchanged list is not rewritten (objectui#11678) An object, dashboard, page or report entry in "Recently Accessed" now stores its type and name only. The route tracker no longer mints a label with titleize ("Showcase Ops Dashboard" for a dashboard labelled "Delivery Operations"); every surface that renders an entry (Home cards, Home rail, sidebar Recent, Studio home, the command palette) asks useRecentItemLabel, which reuses the resolver the console already has for that kind, in the current language. The provider decides writes outside state updaters and refuses to write a list equal to the one it holds: re-adding the entry already at the head is a no-op (no fresher visitedAt), so revisiting the most recent item no longer PATCHes sys_user_preference. Lists stored in the old shape are read by identity (the name is in the id) and their minted label is dropped. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../app-shell/src/chrome/CommandPalette.tsx | 8 +- .../app-shell/src/console/home/HomeRail.tsx | 6 +- .../app-shell/src/console/home/RecentApps.tsx | 6 +- .../HomeItemTypeLabel.parity.test.tsx | 12 +- .../RecentApps.labelAtRender-11678.test.tsx | 218 ++++++++++++++++ .../src/context/RecentItemsProvider.tsx | 169 +++++++++--- .../__tests__/RecentItemsProvider.test.tsx | 243 ++++++++++++++++-- .../__tests__/useRecentItemLabel.test.tsx | 92 +++++++ .../__tests__/useTrackRouteAsRecent.test.tsx | 156 +++++++++-- .../app-shell/src/hooks/useNavTargetLabel.ts | 13 +- .../app-shell/src/hooks/useRecentItemLabel.ts | 80 ++++++ .../app-shell/src/hooks/useRecentItems.ts | 11 +- .../src/hooks/useTrackRouteAsRecent.ts | 56 ++-- packages/app-shell/src/index.ts | 6 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 8 +- ...tudioHomePage.displayLocale-10232.test.tsx | 9 +- .../views/metadata-admin/StudioHomePage.tsx | 5 +- 17 files changed, 984 insertions(+), 114 deletions(-) create mode 100644 packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx create mode 100644 packages/app-shell/src/hooks/__tests__/useRecentItemLabel.test.tsx create mode 100644 packages/app-shell/src/hooks/useRecentItemLabel.ts 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/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx index d830bf11a9..aeb6364537 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 { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; import { recentItemTypeLabel } from './recentItemTypeLabel.js'; import { timeAgo } from '../../utils/relativeTime.js'; @@ -237,6 +238,9 @@ const RECENT_TONE: Record = { }; export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen: (href: string) => void; t: TFn }) { + // Resolved on this render, in the current language — the entry stores + // identity, not text (objectui#11678). + const recentLabel = useRecentItemLabel(); return ( {items.length === 0 ? ( @@ -250,7 +254,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={recentLabel(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..dad6262b82 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 @@ -98,15 +98,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..f962c3e159 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/RecentApps.labelAtRender-11678.test.tsx @@ -0,0 +1,218 @@ +/** + * 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 the real + * `RecentApps` renders them through `useRecentItemLabel` over a real + * `MetadataCtx` and a real i18next instance. 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 { act, cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { createI18n, I18nProvider } 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 { 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 ; +} + +function tree(i18n: ReturnType, language: keyof typeof SERVED, pathname: string) { + return ( + + + + + + + + + + + + + ); +} + +/** 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('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..7f3e9a9c43 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,49 @@ import { // Types // --------------------------------------------------------------------------- -export interface RecentItem { +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: 'object' | 'dashboard' | 'page' | 'report'; + /** 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: 'record' | 'metadata'; + 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 +98,72 @@ 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 { + return item.type === 'record' || item.type === 'metadata' + ? JSON.stringify([item.id, item.type, item.href, item.label]) + : 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 +194,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 +225,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 +235,7 @@ export function RecentItemsProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; }; - }, [adapter, userId]); + }, [adapter, userId, adopt]); const flusher = useMemo( () => createDebouncedFlush(async items => { @@ -140,8 +245,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 +261,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..4408d70cc6 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,208 @@ 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(); + mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; + }); + + /** 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(); + mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; + }); + + 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..0024d581dd 100644 --- a/packages/app-shell/src/hooks/useRecentItems.ts +++ b/packages/app-shell/src/hooks/useRecentItems.ts @@ -11,4 +11,13 @@ * @module */ -export { useRecentItems, type RecentItem } from '../context/RecentItemsProvider.js'; +export { + useRecentItems, + type RecentItem, + type RecentItemInput, + type RecentNamedItem, + type RecentTextItem, +} from '../context/RecentItemsProvider.js'; +// An object / dashboard / page / report entry stores no label; this is how a +// surface that renders one asks for it (objectui#11678). +export { useRecentItemLabel, type RecentItemLabelResolver } from './useRecentItemLabel.js'; 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 6246d77840..374c756a52 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -45,7 +45,7 @@ export { installSettleSignalGlobal, type ObjectUiGlobal, } from './observability/settleSignal.js'; -export { useRecentItems } from './hooks/useRecentItems.js'; +export { useRecentItems, useRecentItemLabel } from './hooks/useRecentItems.js'; // Types export type { @@ -64,6 +64,10 @@ export type { export type { RecentItem, + RecentItemInput, + RecentNamedItem, + RecentTextItem, + RecentItemLabelResolver, } from './hooks/useRecentItems.js'; // Console building blocks — compose these in your App.tsx to build the console 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)} From c844f8ab6e67df4fa6f3393b5ed548d86a8321ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:47:08 +0000 Subject: [PATCH 2/4] docs(app-shell): changeset and docs for identity-stored recent items; narrow contentOf by switch (objectui#11678) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11678-recent-items-identity.md | 15 +++++++++++++++ content/docs/guide/user-state-persistence.md | 7 +++++++ packages/app-shell/README.md | 17 +++++++++++++++++ .../src/context/RecentItemsProvider.tsx | 10 +++++++--- .../__tests__/RecentItemsProvider.test.tsx | 2 -- 5 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .changeset/11678-recent-items-identity.md diff --git a/.changeset/11678-recent-items-identity.md b/.changeset/11678-recent-items-identity.md new file mode 100644 index 0000000000..59c2378e18 --- /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`, `RecentNamedItem` and `RecentTextItem` are new exports of `@object-ui/app-shell`. + +**Clause-②: yes (narrowing).** The package entry gains five 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 80240f3ed1..d08e8a3b80 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -896,6 +896,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/context/RecentItemsProvider.tsx b/packages/app-shell/src/context/RecentItemsProvider.tsx index 7f3e9a9c43..2c5881a32d 100644 --- a/packages/app-shell/src/context/RecentItemsProvider.tsx +++ b/packages/app-shell/src/context/RecentItemsProvider.tsx @@ -155,9 +155,13 @@ function readList(raw: unknown): RecentItem[] { /** What makes an entry this entry — everything but its visit time. */ function contentOf(item: RecentItem): string { - return item.type === 'record' || item.type === 'metadata' - ? JSON.stringify([item.id, item.type, item.href, item.label]) - : JSON.stringify([item.id, item.type, item.href, item.name]); + 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[] { diff --git a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx index 4408d70cc6..cc82769f45 100644 --- a/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx +++ b/packages/app-shell/src/context/__tests__/RecentItemsProvider.test.tsx @@ -181,7 +181,6 @@ describe('RecentItemsProvider — no change, no write (objectui#11678)', () => { beforeEach(() => { localStorage.clear(); - mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; }); /** An attached, hydrated adapter whose saves are counted from zero. */ @@ -332,7 +331,6 @@ describe('RecentItemsProvider — reading a list stored before the identity shap beforeEach(() => { localStorage.clear(); - mockUser.current = { id: 'user-1', name: 'Alice', email: 'a@x' } as any; }); it('from localStorage: the identity is read out of the id, the minted label is dropped', () => { From ec51ed9d6850daaecc96d0a64e882f1759b9c671 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:00:47 +0000 Subject: [PATCH 3/4] fix(app-shell): name the recent-item kind union so the itemType key family stays checked (objectui#11678) check:i18n-keys reads the home.recentApps.itemType.* members off the `type` field of `interface RecentItem`; RecentItem is now a union of two entry shapes, which that reader cannot parse. The kinds are declared once as RecentItemType, both shapes take their arms from it, and the family entry points at it. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11678-recent-items-identity.md | 4 ++-- .../app-shell/src/context/RecentItemsProvider.tsx | 12 ++++++++++-- packages/app-shell/src/hooks/useRecentItems.ts | 1 + packages/app-shell/src/index.ts | 1 + scripts/check-i18n-call-site-keys.mjs | 4 +++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.changeset/11678-recent-items-identity.md b/.changeset/11678-recent-items-identity.md index 59c2378e18..f73eb84a08 100644 --- a/.changeset/11678-recent-items-identity.md +++ b/.changeset/11678-recent-items-identity.md @@ -10,6 +10,6 @@ Lists already stored in the old shape are read by identity, which was always in `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`, `RecentNamedItem` and `RecentTextItem` are new exports of `@object-ui/app-shell`. +**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 five exports, and `addRecentItem` refuses the label-carrying shape it used to take for those four kinds. +**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/packages/app-shell/src/context/RecentItemsProvider.tsx b/packages/app-shell/src/context/RecentItemsProvider.tsx index 2c5881a32d..2dedb2f431 100644 --- a/packages/app-shell/src/context/RecentItemsProvider.tsx +++ b/packages/app-shell/src/context/RecentItemsProvider.tsx @@ -45,6 +45,14 @@ import { // Types // --------------------------------------------------------------------------- +/** + * 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; @@ -65,7 +73,7 @@ interface RecentItemBase { * labelled "Delivery Operations". */ export interface RecentNamedItem extends RecentItemBase { - type: 'object' | 'dashboard' | 'page' | 'report'; + type: Exclude; /** The item's machine name: the object's, dashboard's, page's or report's `name`. */ name: string; } @@ -76,7 +84,7 @@ export interface RecentNamedItem extends RecentItemBase { * display title (data read off the record), a metadata item's name. */ export interface RecentTextItem extends RecentItemBase { - type: 'record' | 'metadata'; + type: Extract; label: string; } diff --git a/packages/app-shell/src/hooks/useRecentItems.ts b/packages/app-shell/src/hooks/useRecentItems.ts index 0024d581dd..e544ecc6e8 100644 --- a/packages/app-shell/src/hooks/useRecentItems.ts +++ b/packages/app-shell/src/hooks/useRecentItems.ts @@ -15,6 +15,7 @@ export { useRecentItems, type RecentItem, type RecentItemInput, + type RecentItemType, type RecentNamedItem, type RecentTextItem, } from '../context/RecentItemsProvider.js'; diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 374c756a52..9f095357ae 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -65,6 +65,7 @@ export type { export type { RecentItem, RecentItemInput, + RecentItemType, RecentNamedItem, RecentTextItem, RecentItemLabelResolver, 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.', From 0c8198b2bff23dc1e286ee8891c7a66537f1fc80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:40:10 +0000 Subject: [PATCH 4/4] fix(app-shell): keep the metadata cache out of list-only importers' graphs (objectui#11678) The useRecentItems shim re-exported useRecentItemLabel, so every module that only wanted the list (RecordDetailView among them) loaded MetadataProvider and metadataClientFactory's module-load createAuthenticatedFetch(); five RecordDetailView suites whose @object-ui/auth mock closes over a top-level spy then hit it in its TDZ. The shim is hooks and types again and the entry exports useRecentItemLabel from its own module. HomeContinue takes the resolver as a prop from HomePage, as it takes t, so HomeRail.tsx (which also holds the action center and activity card) does not load the cache either; a pin on that /home rail joins the label-at-render suite. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../app-shell/src/console/home/HomePage.tsx | 6 ++- .../app-shell/src/console/home/HomeRail.tsx | 26 +++++++--- .../HomeItemTypeLabel.parity.test.tsx | 5 +- .../RecentApps.labelAtRender-11678.test.tsx | 52 ++++++++++++++++--- .../app-shell/src/hooks/useRecentItems.ts | 7 +-- packages/app-shell/src/index.ts | 7 ++- 6 files changed, 84 insertions(+), 19 deletions(-) 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 aeb6364537..ebb8def5c8 100644 --- a/packages/app-shell/src/console/home/HomeRail.tsx +++ b/packages/app-shell/src/console/home/HomeRail.tsx @@ -20,7 +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 { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; +import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js'; import { recentItemTypeLabel } from './recentItemTypeLabel.js'; import { timeAgo } from '../../utils/relativeTime.js'; @@ -237,10 +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 }) { - // Resolved on this render, in the current language — the entry stores - // identity, not text (objectui#11678). - const recentLabel = useRecentItemLabel(); +/** + * `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 ? ( @@ -254,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={recentLabel(it)} + label={labelOf(it)} meta={recentItemTypeLabel(t, it.type)} onClick={() => onOpen(it.href)} /> 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 dad6262b82..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} />; } /** 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 index f962c3e159..0d8f66ac92 100644 --- 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 @@ -17,9 +17,11 @@ * 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 the real - * `RecentApps` renders them through `useRecentItemLabel` over a real - * `MetadataCtx` and a real i18next instance. The metadata is what the `/meta` + * 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: @@ -33,10 +35,11 @@ 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 } from '@object-ui/i18n'; +import { createI18n, I18nProvider, useObjectTranslation } from '@object-ui/i18n'; vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), @@ -44,6 +47,8 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ })); 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'; @@ -116,7 +121,20 @@ function Home() { return ; } -function tree(i18n: ReturnType, language: keyof typeof SERVED, pathname: string) { +/** 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 ( @@ -124,7 +142,7 @@ function tree(i18n: ReturnType, language: keyof typeof SERVED, p - + @@ -133,6 +151,13 @@ function tree(i18n: ReturnType, language: keyof typeof SERVED, p ); } +/** 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 @@ -173,6 +198,21 @@ describe('"Recently Accessed" labels each item when it is rendered (objectui#116 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', diff --git a/packages/app-shell/src/hooks/useRecentItems.ts b/packages/app-shell/src/hooks/useRecentItems.ts index e544ecc6e8..15998114f6 100644 --- a/packages/app-shell/src/hooks/useRecentItems.ts +++ b/packages/app-shell/src/hooks/useRecentItems.ts @@ -19,6 +19,7 @@ export { type RecentNamedItem, type RecentTextItem, } from '../context/RecentItemsProvider.js'; -// An object / dashboard / page / report entry stores no label; this is how a -// surface that renders one asks for it (objectui#11678). -export { useRecentItemLabel, type RecentItemLabelResolver } from './useRecentItemLabel.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/index.ts b/packages/app-shell/src/index.ts index 1bb04c4e9a..456fcfbd77 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -45,7 +45,10 @@ export { installSettleSignalGlobal, type ObjectUiGlobal, } from './observability/settleSignal.js'; -export { useRecentItems, useRecentItemLabel } from './hooks/useRecentItems.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 { @@ -68,8 +71,8 @@ export type { RecentItemType, RecentNamedItem, RecentTextItem, - RecentItemLabelResolver, } 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.