@object-ui/app-shell 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,39 +5,38 @@ import { jsx as _jsx } from "react/jsx-runtime";
5
5
  * React Context + Provider for shared favorites state across all consumers
6
6
  * (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
7
7
  *
8
- * Replaces the standalone `useFavorites` hook pattern — all callers now share
9
- * a single state instance so that toggling a star in AppCard immediately
10
- * reflects in HomePage's Starred section and sidebar.
11
- *
12
- * Persistence: localStorage (key: "objectui-favorites", max 20 items).
13
- *
14
- * TODO: Migrate persistence to server-side storage via the adapter/API layer
15
- * (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers.
16
- * The provider should accept an optional `persistenceAdapter` prop that implements
17
- * `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
18
- * When the adapter is provided, localStorage should be used only as a fallback
19
- * during the initial load while the server response is in-flight.
8
+ * Persistence is **localStorage-first** with optional backend hydration:
9
+ * - On mount we render synchronously from localStorage (no flash of empty UI).
10
+ * - If a `UserDataAdapter<FavoriteItem>` is attached via `UserStateAdaptersProvider`
11
+ * (see `./UserStateAdapters`), the provider hydrates from the backend and
12
+ * writes-through every mutation through a debounced `adapter.save()`.
13
+ * - localStorage stays in sync so offline / pre-auth sessions still work and
14
+ * gives an instant first paint after sign-in.
15
+ * - The local key is scoped per `user.id` so different accounts on the same
16
+ * browser don't cross-contaminate.
20
17
  *
21
18
  * @module
22
19
  */
23
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
20
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
21
+ import { useAuth } from '@object-ui/auth';
22
+ import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, } from './UserStateAdapters';
24
23
  // ---------------------------------------------------------------------------
25
24
  // Storage helpers
26
25
  // ---------------------------------------------------------------------------
27
- const STORAGE_KEY = 'objectui-favorites';
26
+ const STORAGE_BASE_KEY = 'objectui-favorites';
28
27
  const MAX_FAVORITES = 20;
29
- function loadFavorites() {
28
+ function loadFavorites(userId) {
30
29
  try {
31
- const raw = localStorage.getItem(STORAGE_KEY);
30
+ const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
32
31
  return raw ? JSON.parse(raw) : [];
33
32
  }
34
33
  catch {
35
34
  return [];
36
35
  }
37
36
  }
38
- function saveFavorites(items) {
37
+ function saveFavorites(items, userId) {
39
38
  try {
40
- localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
39
+ localStorage.setItem(scopedKey(STORAGE_BASE_KEY, userId), JSON.stringify(items));
41
40
  }
42
41
  catch {
43
42
  // Storage full — silently ignore
@@ -48,12 +47,64 @@ function saveFavorites(items) {
48
47
  // ---------------------------------------------------------------------------
49
48
  const FavoritesContext = createContext(null);
50
49
  export function FavoritesProvider({ children }) {
51
- const [favorites, setFavorites] = useState(loadFavorites);
52
- // Re-sync from storage on mount (handles cases where storage was written
53
- // before this provider was mounted, e.g. on initial page load).
50
+ const { user } = useAuth();
51
+ const userId = user?.id ?? null;
52
+ const adapter = useUserStateAdapter('favorites');
53
+ const [favorites, setFavorites] = useState(() => loadFavorites(userId));
54
+ // Re-load from localStorage whenever the active user changes (sign-in /
55
+ // account switch). Prevents one account from seeing another's favorites.
54
56
  useEffect(() => {
55
- setFavorites(loadFavorites());
56
- }, []);
57
+ setFavorites(loadFavorites(userId));
58
+ }, [userId]);
59
+ // Cross-tab sync: when another tab writes the same scoped key, mirror the
60
+ // change locally. `storage` events do not fire in the tab that wrote them,
61
+ // so this never echoes our own mutations.
62
+ useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
63
+ setFavorites(Array.isArray(value)
64
+ ? value.filter(it => it && typeof it.id === 'string').slice(0, MAX_FAVORITES)
65
+ : []);
66
+ });
67
+ // Hydrate from backend whenever an adapter is attached (or user changes).
68
+ // Backend wins on conflict; we then write through to localStorage so the
69
+ // next reload is instant.
70
+ const hydrationToken = useRef(0);
71
+ useEffect(() => {
72
+ if (!adapter)
73
+ return;
74
+ const token = ++hydrationToken.current;
75
+ let cancelled = false;
76
+ void (async () => {
77
+ try {
78
+ const remote = await adapter.load();
79
+ if (cancelled || token !== hydrationToken.current)
80
+ return;
81
+ // Defensive sanitize: drop unparseable shapes, enforce cap.
82
+ const sane = (Array.isArray(remote) ? remote : [])
83
+ .filter(it => it && typeof it.id === 'string')
84
+ .slice(0, MAX_FAVORITES);
85
+ setFavorites(sane);
86
+ saveFavorites(sane, userId);
87
+ }
88
+ catch {
89
+ // Keep localStorage state — adapter degrade-to-noop is acceptable.
90
+ }
91
+ })();
92
+ return () => {
93
+ cancelled = true;
94
+ };
95
+ }, [adapter, userId]);
96
+ // Debounced write-through to backend.
97
+ const flusher = useMemo(() => createDebouncedFlush(async (items) => {
98
+ if (adapter)
99
+ await adapter.save(items);
100
+ }, 500), [adapter]);
101
+ // Flush pending writes when the adapter detaches / user changes.
102
+ useEffect(() => () => { void flusher.flush(); }, [flusher]);
103
+ const commit = useCallback((next) => {
104
+ saveFavorites(next, userId);
105
+ if (adapter)
106
+ flusher.schedule(next);
107
+ }, [userId, adapter, flusher]);
57
108
  const addFavorite = useCallback((item) => {
58
109
  setFavorites(prev => {
59
110
  if (prev.some(f => f.id === item.id))
@@ -62,44 +113,39 @@ export function FavoritesProvider({ children }) {
62
113
  { ...item, favoritedAt: new Date().toISOString() },
63
114
  ...prev,
64
115
  ].slice(0, MAX_FAVORITES);
65
- saveFavorites(updated);
116
+ commit(updated);
66
117
  return updated;
67
118
  });
68
- }, []);
119
+ }, [commit]);
69
120
  const removeFavorite = useCallback((id) => {
70
121
  setFavorites(prev => {
71
122
  const updated = prev.filter(f => f.id !== id);
72
- saveFavorites(updated);
123
+ commit(updated);
73
124
  return updated;
74
125
  });
75
- }, []);
126
+ }, [commit]);
76
127
  const toggleFavorite = useCallback((item) => {
77
128
  setFavorites(prev => {
78
129
  const exists = prev.some(f => f.id === item.id);
79
130
  const updated = exists
80
131
  ? prev.filter(f => f.id !== item.id)
81
132
  : [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(0, MAX_FAVORITES);
82
- saveFavorites(updated);
133
+ commit(updated);
83
134
  return updated;
84
135
  });
85
- }, []);
136
+ }, [commit]);
86
137
  const clearFavorites = useCallback(() => {
87
138
  setFavorites([]);
88
- saveFavorites([]);
89
- }, []);
139
+ commit([]);
140
+ }, [commit]);
90
141
  const value = useMemo(() => ({
91
142
  favorites,
92
143
  addFavorite,
93
144
  removeFavorite,
94
145
  toggleFavorite,
95
- // Inlined here so useMemo sees the freshest `favorites` without needing
96
- // a separate useCallback([favorites]) entry in the deps array.
97
146
  isFavorite: (id) => favorites.some(f => f.id === id),
98
147
  clearFavorites,
99
- }),
100
- // addFavorite / removeFavorite / toggleFavorite / clearFavorites are all
101
- // stable (useCallback with [] deps) and never cause extra re-renders.
102
- [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites]);
148
+ }), [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites]);
103
149
  return (_jsx(FavoritesContext.Provider, { value: value, children: children }));
104
150
  }
105
151
  // ---------------------------------------------------------------------------
@@ -0,0 +1,45 @@
1
+ /**
2
+ * RecentItemsProvider
3
+ *
4
+ * Shared "recently-accessed" state with optional backend persistence. Mirrors
5
+ * the design of `FavoritesProvider`:
6
+ *
7
+ * - localStorage-first: instant first paint, works offline / pre-auth.
8
+ * - Hydrates from `UserDataAdapter<RecentItem>` when one is attached via
9
+ * `UserStateAdaptersProvider` (typically by `ConnectedShell`'s bridge).
10
+ * - Writes are debounced and pushed to the adapter; localStorage stays in
11
+ * sync as a cold-start cache.
12
+ * - Storage key is scoped by `user.id` to avoid cross-account leakage.
13
+ *
14
+ * The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is
15
+ * preserved via a re-export shim so existing call sites keep working.
16
+ *
17
+ * @module
18
+ */
19
+ import { type ReactNode } from 'react';
20
+ export interface RecentItem {
21
+ /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
22
+ id: string;
23
+ label: string;
24
+ href: string;
25
+ type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
26
+ /** ISO timestamp of last visit */
27
+ visitedAt: string;
28
+ }
29
+ interface RecentItemsContextValue {
30
+ recentItems: RecentItem[];
31
+ addRecentItem: (item: Omit<RecentItem, 'visitedAt'>) => void;
32
+ clearRecentItems: () => void;
33
+ }
34
+ export declare function RecentItemsProvider({ children }: {
35
+ children: ReactNode;
36
+ }): import("react/jsx-runtime").JSX.Element;
37
+ /**
38
+ * Access shared "recently-accessed" state.
39
+ *
40
+ * Falls back to a no-op implementation when used outside a
41
+ * `<RecentItemsProvider>` (e.g. unit tests that only render presentational
42
+ * components).
43
+ */
44
+ export declare function useRecentItems(): RecentItemsContextValue;
45
+ export {};
@@ -0,0 +1,139 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * RecentItemsProvider
4
+ *
5
+ * Shared "recently-accessed" state with optional backend persistence. Mirrors
6
+ * the design of `FavoritesProvider`:
7
+ *
8
+ * - localStorage-first: instant first paint, works offline / pre-auth.
9
+ * - Hydrates from `UserDataAdapter<RecentItem>` when one is attached via
10
+ * `UserStateAdaptersProvider` (typically by `ConnectedShell`'s bridge).
11
+ * - Writes are debounced and pushed to the adapter; localStorage stays in
12
+ * sync as a cold-start cache.
13
+ * - Storage key is scoped by `user.id` to avoid cross-account leakage.
14
+ *
15
+ * The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is
16
+ * preserved via a re-export shim so existing call sites keep working.
17
+ *
18
+ * @module
19
+ */
20
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
21
+ import { useAuth } from '@object-ui/auth';
22
+ import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, } from './UserStateAdapters';
23
+ // ---------------------------------------------------------------------------
24
+ // Storage helpers
25
+ // ---------------------------------------------------------------------------
26
+ const STORAGE_BASE_KEY = 'objectui-recent-items';
27
+ const MAX_RECENT = 8;
28
+ function loadRecent(userId) {
29
+ try {
30
+ const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
31
+ return raw ? JSON.parse(raw) : [];
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ }
37
+ function saveRecent(items, userId) {
38
+ try {
39
+ localStorage.setItem(scopedKey(STORAGE_BASE_KEY, userId), JSON.stringify(items));
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Context
47
+ // ---------------------------------------------------------------------------
48
+ const RecentItemsContext = createContext(null);
49
+ // ---------------------------------------------------------------------------
50
+ // Provider
51
+ // ---------------------------------------------------------------------------
52
+ export function RecentItemsProvider({ children }) {
53
+ const { user } = useAuth();
54
+ const userId = user?.id ?? null;
55
+ const adapter = useUserStateAdapter('recent');
56
+ const [recentItems, setRecentItems] = useState(() => loadRecent(userId));
57
+ useEffect(() => {
58
+ setRecentItems(loadRecent(userId));
59
+ }, [userId]);
60
+ // Cross-tab sync — see FavoritesProvider for the rationale.
61
+ useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
62
+ setRecentItems(Array.isArray(value)
63
+ ? value.filter(it => it && typeof it.id === 'string').slice(0, MAX_RECENT)
64
+ : []);
65
+ });
66
+ const hydrationToken = useRef(0);
67
+ useEffect(() => {
68
+ if (!adapter)
69
+ return;
70
+ const token = ++hydrationToken.current;
71
+ let cancelled = false;
72
+ void (async () => {
73
+ try {
74
+ const remote = await adapter.load();
75
+ if (cancelled || token !== hydrationToken.current)
76
+ return;
77
+ const sane = (Array.isArray(remote) ? remote : [])
78
+ .filter(it => it && typeof it.id === 'string')
79
+ .slice(0, MAX_RECENT);
80
+ setRecentItems(sane);
81
+ saveRecent(sane, userId);
82
+ }
83
+ catch {
84
+ // ignore — degrade to localStorage
85
+ }
86
+ })();
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [adapter, userId]);
91
+ const flusher = useMemo(() => createDebouncedFlush(async (items) => {
92
+ if (adapter)
93
+ await adapter.save(items);
94
+ }, 500), [adapter]);
95
+ useEffect(() => () => { void flusher.flush(); }, [flusher]);
96
+ const commit = useCallback((next) => {
97
+ saveRecent(next, userId);
98
+ if (adapter)
99
+ flusher.schedule(next);
100
+ }, [userId, adapter, flusher]);
101
+ const addRecentItem = useCallback((item) => {
102
+ setRecentItems(prev => {
103
+ const filtered = prev.filter(r => r.id !== item.id);
104
+ const updated = [
105
+ { ...item, visitedAt: new Date().toISOString() },
106
+ ...filtered,
107
+ ].slice(0, MAX_RECENT);
108
+ commit(updated);
109
+ return updated;
110
+ });
111
+ }, [commit]);
112
+ const clearRecentItems = useCallback(() => {
113
+ setRecentItems([]);
114
+ commit([]);
115
+ }, [commit]);
116
+ const value = useMemo(() => ({ recentItems, addRecentItem, clearRecentItems }), [recentItems, addRecentItem, clearRecentItems]);
117
+ return (_jsx(RecentItemsContext.Provider, { value: value, children: children }));
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Hook
121
+ // ---------------------------------------------------------------------------
122
+ /**
123
+ * Access shared "recently-accessed" state.
124
+ *
125
+ * Falls back to a no-op implementation when used outside a
126
+ * `<RecentItemsProvider>` (e.g. unit tests that only render presentational
127
+ * components).
128
+ */
129
+ export function useRecentItems() {
130
+ const ctx = useContext(RecentItemsContext);
131
+ if (!ctx) {
132
+ return {
133
+ recentItems: [],
134
+ addRecentItem: () => { },
135
+ clearRecentItems: () => { },
136
+ };
137
+ }
138
+ return ctx;
139
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * UserStateAdapters
3
+ *
4
+ * Injection point for backend persistence of per-user UI state (favorites,
5
+ * recently-accessed items, …). Keeps `FavoritesProvider` / `RecentItemsProvider`
6
+ * decoupled from any specific backend (REST, ObjectQL, GraphQL).
7
+ *
8
+ * The provider is stateful: a bridge component mounted lower in the tree
9
+ * (where the data adapter + authenticated user are available) calls
10
+ * `useAttachUserStateAdapters()` to inject adapters at runtime. While no
11
+ * adapter is attached, hosting providers transparently fall back to
12
+ * localStorage-only behaviour.
13
+ *
14
+ * @module
15
+ */
16
+ import { type ReactNode } from 'react';
17
+ /**
18
+ * Generic persistence adapter for a user-scoped list.
19
+ *
20
+ * Implementations must be safe to call concurrently and should *never*
21
+ * throw — failures should be swallowed so the hosting provider can degrade
22
+ * to localStorage-only mode without crashing the UI.
23
+ */
24
+ export interface UserDataAdapter<T> {
25
+ /** Load the persisted list for the current user. Resolve to [] when absent. */
26
+ load(): Promise<T[]>;
27
+ /** Persist the full list (debounced upstream). Errors are silently ignored. */
28
+ save(items: T[]): Promise<void>;
29
+ }
30
+ export type UserStateKind = 'favorites' | 'recent';
31
+ export declare function UserStateAdaptersProvider({ children }: {
32
+ children: ReactNode;
33
+ }): import("react/jsx-runtime").JSX.Element;
34
+ /** Read the currently-attached adapter for a given kind. */
35
+ export declare function useUserStateAdapter<T>(kind: UserStateKind): UserDataAdapter<T> | null;
36
+ /**
37
+ * Imperative API used by bridge components to plug an adapter in/out.
38
+ * Returns a stable function across re-renders.
39
+ */
40
+ export declare function useAttachUserStateAdapters(): (kind: UserStateKind, adapter: UserDataAdapter<any> | null) => void;
41
+ /**
42
+ * Build a user-scoped localStorage key. Falls back to the unscoped key for
43
+ * unauthenticated / preview sessions to preserve current behaviour.
44
+ */
45
+ export declare function scopedKey(base: string, userId?: string | null): string;
46
+ /**
47
+ * Subscribe to cross-tab updates of a specific localStorage key. The browser
48
+ * fires `storage` events only in *other* tabs, so we never echo our own
49
+ * writes. `onValue` receives the parsed JSON payload (or null if the key was
50
+ * removed / unparseable).
51
+ */
52
+ export declare function useStorageSync<T>(key: string, onValue: (value: T | null) => void): void;
53
+ /**
54
+ * Tiny debounced flush helper. Returns `{ schedule, flush, cancel }`.
55
+ * Designed for the "save full list to backend" pattern.
56
+ */
57
+ export declare function createDebouncedFlush<T>(fn: (value: T) => Promise<void> | void, delayMs?: number): {
58
+ schedule: (value: T) => void;
59
+ flush: () => Promise<void>;
60
+ cancel: () => void;
61
+ };
@@ -0,0 +1,141 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * UserStateAdapters
4
+ *
5
+ * Injection point for backend persistence of per-user UI state (favorites,
6
+ * recently-accessed items, …). Keeps `FavoritesProvider` / `RecentItemsProvider`
7
+ * decoupled from any specific backend (REST, ObjectQL, GraphQL).
8
+ *
9
+ * The provider is stateful: a bridge component mounted lower in the tree
10
+ * (where the data adapter + authenticated user are available) calls
11
+ * `useAttachUserStateAdapters()` to inject adapters at runtime. While no
12
+ * adapter is attached, hosting providers transparently fall back to
13
+ * localStorage-only behaviour.
14
+ *
15
+ * @module
16
+ */
17
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
18
+ // ---------------------------------------------------------------------------
19
+ // Contexts (split read / write so consumers don't re-render unnecessarily)
20
+ // ---------------------------------------------------------------------------
21
+ const ReadCtx = createContext({ favorites: null, recent: null });
22
+ const WriteCtx = createContext(null);
23
+ // ---------------------------------------------------------------------------
24
+ // Provider
25
+ // ---------------------------------------------------------------------------
26
+ export function UserStateAdaptersProvider({ children }) {
27
+ const [adapters, setAdapters] = useState({
28
+ favorites: null,
29
+ recent: null,
30
+ });
31
+ // Stable attach API so bridge useEffect deps don't churn.
32
+ const attachRef = useRef({
33
+ attach: (kind, adapter) => {
34
+ setAdapters(prev => (prev[kind] === adapter ? prev : { ...prev, [kind]: adapter }));
35
+ },
36
+ });
37
+ return (_jsx(WriteCtx.Provider, { value: attachRef.current, children: _jsx(ReadCtx.Provider, { value: adapters, children: children }) }));
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Hooks
41
+ // ---------------------------------------------------------------------------
42
+ /** Read the currently-attached adapter for a given kind. */
43
+ export function useUserStateAdapter(kind) {
44
+ return useContext(ReadCtx)[kind];
45
+ }
46
+ /**
47
+ * Imperative API used by bridge components to plug an adapter in/out.
48
+ * Returns a stable function across re-renders.
49
+ */
50
+ export function useAttachUserStateAdapters() {
51
+ const ctx = useContext(WriteCtx);
52
+ return useCallback((kind, adapter) => {
53
+ ctx?.attach(kind, adapter);
54
+ }, [ctx]);
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Shared helpers (used by FavoritesProvider / RecentItemsProvider)
58
+ // ---------------------------------------------------------------------------
59
+ /**
60
+ * Build a user-scoped localStorage key. Falls back to the unscoped key for
61
+ * unauthenticated / preview sessions to preserve current behaviour.
62
+ */
63
+ export function scopedKey(base, userId) {
64
+ return userId ? `${base}:u:${userId}` : base;
65
+ }
66
+ /**
67
+ * Subscribe to cross-tab updates of a specific localStorage key. The browser
68
+ * fires `storage` events only in *other* tabs, so we never echo our own
69
+ * writes. `onValue` receives the parsed JSON payload (or null if the key was
70
+ * removed / unparseable).
71
+ */
72
+ export function useStorageSync(key, onValue) {
73
+ // Keep the latest callback in a ref so re-renders of the caller don't
74
+ // tear down and re-add the window listener.
75
+ const cbRef = useRef(onValue);
76
+ cbRef.current = onValue;
77
+ useEffect(() => {
78
+ if (typeof window === 'undefined')
79
+ return;
80
+ const handler = (e) => {
81
+ if (e.key !== key || e.storageArea !== localStorage)
82
+ return;
83
+ if (e.newValue == null) {
84
+ cbRef.current(null);
85
+ return;
86
+ }
87
+ try {
88
+ cbRef.current(JSON.parse(e.newValue));
89
+ }
90
+ catch {
91
+ cbRef.current(null);
92
+ }
93
+ };
94
+ window.addEventListener('storage', handler);
95
+ return () => window.removeEventListener('storage', handler);
96
+ }, [key]);
97
+ }
98
+ /**
99
+ * Tiny debounced flush helper. Returns `{ schedule, flush, cancel }`.
100
+ * Designed for the "save full list to backend" pattern.
101
+ */
102
+ export function createDebouncedFlush(fn, delayMs = 500) {
103
+ let timer = null;
104
+ let pending = null;
105
+ let hasPending = false;
106
+ const flush = async () => {
107
+ if (timer) {
108
+ clearTimeout(timer);
109
+ timer = null;
110
+ }
111
+ if (!hasPending)
112
+ return;
113
+ const payload = pending;
114
+ hasPending = false;
115
+ pending = null;
116
+ try {
117
+ await fn(payload);
118
+ }
119
+ catch {
120
+ // Swallow — adapter is responsible for its own error handling.
121
+ }
122
+ };
123
+ const schedule = (value) => {
124
+ pending = value;
125
+ hasPending = true;
126
+ if (timer)
127
+ clearTimeout(timer);
128
+ timer = setTimeout(() => {
129
+ timer = null;
130
+ void flush();
131
+ }, delayMs);
132
+ };
133
+ const cancel = () => {
134
+ if (timer)
135
+ clearTimeout(timer);
136
+ timer = null;
137
+ hasPending = false;
138
+ pending = null;
139
+ };
140
+ return { schedule, flush, cancel };
141
+ }
@@ -1,3 +1,7 @@
1
1
  export { NavigationProvider, useNavigationContext } from './NavigationContext';
2
2
  export { FavoritesProvider, useFavorites } from './FavoritesProvider';
3
3
  export type { FavoriteItem } from './FavoritesProvider';
4
+ export { RecentItemsProvider, useRecentItems } from './RecentItemsProvider';
5
+ export type { RecentItem } from './RecentItemsProvider';
6
+ export { UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './UserStateAdapters';
7
+ export type { UserDataAdapter, UserStateKind } from './UserStateAdapters';
@@ -1,2 +1,4 @@
1
1
  export { NavigationProvider, useNavigationContext } from './NavigationContext';
2
2
  export { FavoritesProvider, useFavorites } from './FavoritesProvider';
3
+ export { RecentItemsProvider, useRecentItems } from './RecentItemsProvider';
4
+ export { UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './UserStateAdapters';
@@ -6,3 +6,4 @@ export { useObjectActions } from './useObjectActions';
6
6
  export { useRecentItems, type RecentItem } from './useRecentItems';
7
7
  export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
+ export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
@@ -6,3 +6,4 @@ export { useObjectActions } from './useObjectActions';
6
6
  export { useRecentItems } from './useRecentItems';
7
7
  export { useRecordApprovals } from './useRecordApprovals';
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
+ export { useTrackRouteAsRecent } from './useTrackRouteAsRecent';
@@ -1,21 +1,13 @@
1
1
  /**
2
- * useRecentItems
2
+ * useRecentItems — re-export shim
3
+ *
4
+ * The recent-items state has been migrated to a React Context so all consumers
5
+ * share a single state instance and can be backed by an optional backend
6
+ * persistence adapter. See `../context/RecentItemsProvider`.
7
+ *
8
+ * All existing imports of `useRecentItems` and `RecentItem` from this path
9
+ * continue to work without any changes at the call sites.
3
10
  *
4
- * Tracks recently visited items (objects, dashboards, pages) with
5
- * localStorage persistence. Exposes helpers to add and retrieve items.
6
11
  * @module
7
12
  */
8
- export interface RecentItem {
9
- /** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
10
- id: string;
11
- label: string;
12
- href: string;
13
- type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
14
- /** ISO timestamp of last visit */
15
- visitedAt: string;
16
- }
17
- export declare function useRecentItems(): {
18
- recentItems: RecentItem[];
19
- addRecentItem: (item: Omit<RecentItem, "visitedAt">) => void;
20
- clearRecentItems: () => void;
21
- };
13
+ export { useRecentItems, type RecentItem } from '../context/RecentItemsProvider';