feat: support swr local cache (#10884)

* feat: add localstorage cache in swr provider

* feat: add use fetch topic into cache

* feat: add homepage recents api cache

* feat: add group chat initial cache

* docs: update the hint
This commit is contained in:
Shinji-Li
2025-12-22 17:44:39 +08:00
committed by arvinxx
parent 0c5a41f896
commit bc3f3e2a07
22 changed files with 856 additions and 106 deletions
@@ -1,10 +1,12 @@
'use client';
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { Loader2Icon } from 'lucide-react';
import React, { Suspense, memo } from 'react';
import { useTranslation } from 'react-i18next';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
@@ -20,6 +22,7 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
const { t } = useTranslation(['topic', 'common']);
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
const dropdownMenu = useTopicActionsDropdownMenu();
const { isRevalidating } = useFetchTopics();
return (
<AccordionItem
@@ -38,9 +41,12 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
paddingBlock={4}
paddingInline={'8px 4px'}
title={
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
</Text>
<Flexbox align="center" gap={4} horizontal>
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
</Text>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
</Flexbox>
}
>
<Suspense fallback={<SkeletonList />}>
@@ -1,10 +1,11 @@
'use client';
import { AccordionItem, ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { UserPlus } from 'lucide-react';
import { Loader2Icon, UserPlus } from 'lucide-react';
import { MouseEvent, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useInitGroupConfig } from '@/hooks/useInitGroupConfig';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
@@ -22,6 +23,7 @@ const Members = memo<MembersProps>(({ itemKey }) => {
const membersCount = useAgentGroupStore(
agentGroupSelectors.getGroupAgentCount(activeGroupId || ''),
);
const { isRevalidating } = useInitGroupConfig();
const handleAddMember = (e: MouseEvent) => {
e.stopPropagation();
@@ -31,12 +33,15 @@ const Members = memo<MembersProps>(({ itemKey }) => {
return (
<AccordionItem
action={
<ActionIcon
icon={UserPlus}
onClick={handleAddMember}
size={'small'}
title={t('groupSidebar.members.addMember')}
/>
<>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
<ActionIcon
icon={UserPlus}
onClick={handleAddMember}
size={'small'}
title={t('groupSidebar.members.addMember')}
/>
</>
}
itemKey={itemKey}
paddingBlock={4}
@@ -1,10 +1,12 @@
'use client';
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { Loader2Icon } from 'lucide-react';
import React, { Suspense, memo } from 'react';
import { useTranslation } from 'react-i18next';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
@@ -20,6 +22,7 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
const { t } = useTranslation(['topic', 'common']);
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
const dropdownMenu = useTopicActionsDropdownMenu();
const { isRevalidating } = useFetchTopics();
return (
<AccordionItem
@@ -38,9 +41,12 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
paddingBlock={4}
paddingInline={'8px 4px'}
title={
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
</Text>
<Flexbox align="center" gap={4} horizontal>
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{`${t('title')} ${topicCount > 0 ? topicCount : ''}`}
</Text>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
</Flexbox>
}
>
<Suspense fallback={<SkeletonList />}>
@@ -1,9 +1,12 @@
'use client';
import { AccordionItem, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { AccordionItem, ActionIcon, Dropdown, Flexbox, Text } from '@lobehub/ui';
import { Loader2Icon } from 'lucide-react';
import React, { Suspense, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useFetchAgentList } from '@/hooks/useFetchAgentList';
import SkeletonList from '../../../../../../../features/NavPanel/components/SkeletonList';
import { useCreateMenuItems } from '../../hooks';
import Actions from './Actions';
@@ -17,6 +20,7 @@ interface AgentProps {
const Agent = memo<AgentProps>(({ itemKey }) => {
const { t } = useTranslation('common');
const { isRevalidating } = useFetchAgentList();
const {
openGroupWizardModal,
@@ -102,9 +106,12 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
paddingBlock={4}
paddingInline={'8px 4px'}
title={
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{t('navPanel.agent')}
</Text>
<Flexbox align="center" gap={4} horizontal>
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
{t('navPanel.agent')}
</Text>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
</Flexbox>
}
>
<Suspense fallback={<SkeletonList rows={6} />}>
@@ -1,12 +1,13 @@
'use client';
import { ActionIcon, Dropdown } from '@lobehub/ui';
import { FileTextIcon, MoreHorizontal } from 'lucide-react';
import { FileTextIcon, Loader2Icon, MoreHorizontal } from 'lucide-react';
import { Suspense, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
import { useHomeStore } from '@/store/home';
import { homeRecentSelectors } from '@/store/home/selectors';
import { FilesTabs } from '@/types/files';
@@ -23,6 +24,7 @@ const RecentPage = memo(() => {
const setCategory = useResourceManagerStore((s) => s.setCategory);
const recentPages = useHomeStore(homeRecentSelectors.recentPages);
const isInit = useHomeStore(homeRecentSelectors.isRecentPagesInit);
const { isRevalidating } = useInitRecentPage();
// After loaded, if no data, don't render
if (isInit && (!recentPages || recentPages.length === 0)) {
@@ -32,22 +34,25 @@ const RecentPage = memo(() => {
return (
<GroupBlock
action={
<Dropdown
menu={{
items: [
{
key: 'all-documents',
label: t('menu.allPages'),
onClick: () => {
setCategory(FilesTabs.Pages);
navigate('/resource');
<>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
<Dropdown
menu={{
items: [
{
key: 'all-documents',
label: t('menu.allPages'),
onClick: () => {
setCategory(FilesTabs.Pages);
navigate('/resource');
},
},
},
],
}}
>
<ActionIcon icon={MoreHorizontal} size="small" />
</Dropdown>
],
}}
>
<ActionIcon icon={MoreHorizontal} size="small" />
</Dropdown>
</>
}
icon={FileTextIcon}
title={t('home.recentPages')}
@@ -1,12 +1,13 @@
'use client';
import { ActionIcon, Dropdown } from '@lobehub/ui';
import { Clock, MoreHorizontal } from 'lucide-react';
import { Clock, Loader2Icon, MoreHorizontal } from 'lucide-react';
import { Suspense, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
import { useHomeStore } from '@/store/home';
import { homeRecentSelectors } from '@/store/home/selectors';
import { FilesTabs } from '@/types/files';
@@ -23,6 +24,7 @@ const RecentResource = memo(() => {
const setCategory = useResourceManagerStore((s) => s.setCategory);
const recentResources = useHomeStore(homeRecentSelectors.recentResources);
const isInit = useHomeStore(homeRecentSelectors.isRecentResourcesInit);
const { isRevalidating } = useInitRecentResource();
// After loaded, if no data, don't render
if (isInit && (!recentResources || recentResources.length === 0)) {
@@ -32,22 +34,25 @@ const RecentResource = memo(() => {
return (
<GroupBlock
action={
<Dropdown
menu={{
items: [
{
key: 'all-files',
label: t('menu.allFiles'),
onClick: () => {
setCategory(FilesTabs.All);
navigate('/resource');
<>
{isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
<Dropdown
menu={{
items: [
{
key: 'all-files',
label: t('menu.allFiles'),
onClick: () => {
setCategory(FilesTabs.All);
navigate('/resource');
},
},
},
],
}}
>
<ActionIcon icon={MoreHorizontal} size="small" />
</Dropdown>
],
}}
>
<ActionIcon icon={MoreHorizontal} size="small" />
</Dropdown>
</>
}
icon={Clock}
title={t('home.recentFiles')}
@@ -1,7 +1,9 @@
import { BotMessageSquareIcon } from 'lucide-react';
import { ActionIcon } from '@lobehub/ui';
import { BotMessageSquareIcon, Loader2Icon } from 'lucide-react';
import { Suspense, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
import { useHomeStore } from '@/store/home';
import { homeRecentSelectors } from '@/store/home/selectors';
@@ -15,6 +17,7 @@ const RecentTopic = memo(() => {
const { t } = useTranslation('chat');
const recentTopics = useHomeStore(homeRecentSelectors.recentTopics);
const isInit = useHomeStore(homeRecentSelectors.isRecentTopicsInit);
const { isRevalidating } = useInitRecentTopic();
// After loaded, if no data, don't render
if (isInit && (!recentTopics || recentTopics.length === 0)) {
@@ -22,7 +25,11 @@ const RecentTopic = memo(() => {
}
return (
<GroupBlock icon={BotMessageSquareIcon} title={t('topic.recent')}>
<GroupBlock
action={isRevalidating && <ActionIcon icon={Loader2Icon} loading size={'small'} />}
icon={BotMessageSquareIcon}
title={t('topic.recent')}
>
<ScrollShadowWithButton>
<Suspense
fallback={
+11 -2
View File
@@ -2,9 +2,18 @@ import { useHomeStore } from '@/store/home';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
/**
* Hook to fetch agent list
* @returns isValidating - true when background revalidation is in progress (has cached data but fetching new)
*/
export const useFetchAgentList = () => {
const isLogin = useUserStore(authSelectors.isLogin);
const useFetchAgentList = useHomeStore((s) => s.useFetchAgentList);
const useFetchAgentListHook = useHomeStore((s) => s.useFetchAgentList);
useFetchAgentList(isLogin);
const { isValidating, data } = useFetchAgentListHook(isLogin);
// isRevalidating: 有缓存数据,后台正在更新
return {
isRevalidating: isValidating && !!data,
};
};
+7 -2
View File
@@ -9,7 +9,7 @@ import { systemStatusSelectors } from '@/store/global/selectors';
*/
export const useFetchTopics = () => {
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
const [activeAgentId, activeGroupId, useFetchTopics] = useChatStore((s) => [
const [activeAgentId, activeGroupId, useFetchTopicsHook] = useChatStore((s) => [
s.activeAgentId,
s.activeGroupId,
s.useFetchTopics,
@@ -18,10 +18,15 @@ export const useFetchTopics = () => {
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
// If in group session, use groupId; otherwise use agentId
useFetchTopics(true, {
const { isValidating, data } = useFetchTopicsHook(true, {
agentId: activeAgentId,
groupId: activeGroupId,
isInbox: activeGroupId ? false : isInbox,
pageSize: topicPageSize,
});
return {
// isRevalidating: 有缓存数据,后台正在更新
isRevalidating: isValidating && !!data,
};
};
+7 -4
View File
@@ -12,11 +12,14 @@ export const useInitGroupConfig = () => {
// Only fetch group detail if we have a valid group ID and user is logged in
const shouldFetch = Boolean(isLogin && activeGroupId);
const data = useFetchGroupDetail(shouldFetch, activeGroupId || '');
const { isValidating, data, ...rest } = useFetchGroupDetail(shouldFetch, activeGroupId || '');
return {
...data,
error: data.error || (!shouldFetch ? undefined : data.error),
isLoading: (data.isLoading && isLogin) || !shouldFetch,
...rest,
data,
error: rest.error || (!shouldFetch ? undefined : rest.error),
isLoading: (rest.isLoading && isLogin) || !shouldFetch,
// isRevalidating: 有缓存数据,后台正在更新
isRevalidating: isValidating && !!data,
};
};
+8 -2
View File
@@ -18,7 +18,13 @@ export const useInitRecentPage = () => {
const isLogin = useUserStore(authSelectors.isLogin);
const data = useFetchRecentPages(isLogin);
const { isValidating, data, ...rest } = useFetchRecentPages(isLogin);
return { ...data, isLoading: data.isLoading && isLogin };
return {
...rest,
data,
isLoading: rest.isLoading && isLogin,
// isRevalidating: 有缓存数据,后台正在更新
isRevalidating: isValidating && !!data,
};
};
+8 -2
View File
@@ -18,7 +18,13 @@ export const useInitRecentResource = () => {
const isLogin = useUserStore(authSelectors.isLogin);
const data = useFetchRecentResources(isLogin);
const { isValidating, data, ...rest } = useFetchRecentResources(isLogin);
return { ...data, isLoading: data.isLoading && isLogin };
return {
...rest,
data,
isLoading: rest.isLoading && isLogin,
// isRevalidating: 有缓存数据,后台正在更新
isRevalidating: isValidating && !!data,
};
};
+8 -2
View File
@@ -18,7 +18,13 @@ export const useInitRecentTopic = () => {
const isLogin = useUserStore(authSelectors.isLogin);
const data = useFetchRecentTopics(isLogin);
const { isValidating, data, ...rest } = useFetchRecentTopics(isLogin);
return { ...data, isLoading: data.isLoading && isLogin };
return {
...rest,
data,
isLoading: rest.isLoading && isLogin,
// isRevalidating: 有缓存数据,后台正在更新
isRevalidating: isValidating && !!data,
};
};
+10 -3
View File
@@ -2,7 +2,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React, { PropsWithChildren, useState } from 'react';
import { SWRConfig } from 'swr';
import { swrCacheProvider } from '@/libs/swr/localStorageProvider';
import { lambdaQuery, lambdaQueryClient } from '@/libs/trpc/client';
const QueryProvider = ({ children }: PropsWithChildren) => {
@@ -12,10 +14,15 @@ const QueryProvider = ({ children }: PropsWithChildren) => {
typeof lambdaQuery.Provider
>['queryClient'];
// 使用 useState 确保 provider 只创建一次
const [provider] = useState(swrCacheProvider);
return (
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={providerQueryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</lambdaQuery.Provider>
<SWRConfig value={{ provider }}>
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={providerQueryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</lambdaQuery.Provider>
</SWRConfig>
);
};
+4 -1
View File
@@ -57,7 +57,7 @@ export const useClientDataSWR: SWRHook = (key, fetch, config) =>
* This type of request method is relatively "dead" request mode, which will only be triggered on the first request.
* it suitable for first time request like `initUserState`
* 这一类请求方法是相对“死”的请求模式,只会在第一次请求时触发。
* 这一类请求方法是相对"死"的请求模式,只会在第一次请求时触发。
* 适用于第一次请求,例如 `initUserState`
*/
// @ts-ignore
@@ -93,3 +93,6 @@ export interface SWRRefreshParams<T, A = (...args: any[]) => any> {
export type SWRefreshMethod<T> = <A extends (...args: any[]) => Promise<any>>(
params?: SWRRefreshParams<T, A>,
) => ReturnType<A>;
// 导出带自动同步功能的 hook
export { useClientDataSWRWithSync } from './useClientDataSWRWithSync';
+294
View File
@@ -0,0 +1,294 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearSWRCache, createLocalStorageProvider } from './localStorageProvider';
describe('createLocalStorageProvider', () => {
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('basic functionality', () => {
it('should return a function that creates a Map', () => {
const provider = createLocalStorageProvider();
const map = provider();
expect(map).toBeInstanceOf(Map);
});
it('should store and retrieve values', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['test-key'],
});
const map = provider();
map.set('test-key', { value: 'test' });
expect(map.get('test-key')).toEqual({ value: 'test' });
});
it('should delete values', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['test-key'],
});
const map = provider();
map.set('test-key', { value: 'test' });
map.delete('test-key');
expect(map.has('test-key')).toBe(false);
});
});
describe('whitelist filtering', () => {
it('should only persist keys matching cacheablePatterns', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['fetchSessions', 'fetchAgentList'],
});
const map = provider();
// Set both cacheable and non-cacheable keys
map.set('fetchSessions', { sessions: [] });
map.set('fetchAgentList', { agents: [] });
map.set('fetchMessages', { messages: [] }); // Not in whitelist
// Trigger save via beforeunload event
window.dispatchEvent(new Event('beforeunload'));
const stored = localStorage.getItem('lobechat-swr-cache');
expect(stored).not.toBeNull();
const entries = JSON.parse(stored!);
const keys = entries.map(([key]: [string, unknown]) => key);
expect(keys).toContain('fetchSessions');
expect(keys).toContain('fetchAgentList');
expect(keys).not.toContain('fetchMessages');
});
it('should match array keys by first element', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['fetchSessions'],
});
const map = provider();
// SWR often uses array keys like ['fetchSessions', userId]
const arrayKey = JSON.stringify(['fetchSessions', 'user-123']);
map.set(arrayKey, { data: 'test' });
// Trigger save via beforeunload event
window.dispatchEvent(new Event('beforeunload'));
const stored = localStorage.getItem('lobechat-swr-cache');
const entries = JSON.parse(stored!);
expect(entries.length).toBe(1);
});
});
describe('TTL expiration', () => {
it('should load non-expired entries from localStorage', () => {
const now = Date.now();
const validEntry = {
data: { valid: true },
timestamp: now - 1000, // 1 second ago
version: '1.0.0',
};
localStorage.setItem(
'lobechat-swr-cache',
JSON.stringify([['valid-key', validEntry]]),
);
const provider = createLocalStorageProvider({
ttl: 60 * 1000, // 1 minute
version: '1.0.0',
});
const map = provider();
expect(map.get('valid-key')).toEqual({ valid: true });
});
it('should not load expired entries from localStorage', () => {
const now = Date.now();
const expiredEntry = {
data: { expired: true },
timestamp: now - 2 * 60 * 60 * 1000, // 2 hours ago
version: '1.0.0',
};
localStorage.setItem(
'lobechat-swr-cache',
JSON.stringify([['expired-key', expiredEntry]]),
);
const provider = createLocalStorageProvider({
ttl: 60 * 60 * 1000, // 1 hour
version: '1.0.0',
});
const map = provider();
expect(map.has('expired-key')).toBe(false);
});
});
describe('version control', () => {
it('should not load entries with different version', () => {
const oldVersionEntry = {
data: { old: true },
timestamp: Date.now(),
version: '0.9.0',
};
localStorage.setItem(
'lobechat-swr-cache',
JSON.stringify([['old-key', oldVersionEntry]]),
);
const provider = createLocalStorageProvider({
version: '1.0.0',
});
const map = provider();
expect(map.has('old-key')).toBe(false);
});
it('should load entries with matching version', () => {
const currentVersionEntry = {
data: { current: true },
timestamp: Date.now(),
version: '1.0.0',
};
localStorage.setItem(
'lobechat-swr-cache',
JSON.stringify([['current-key', currentVersionEntry]]),
);
const provider = createLocalStorageProvider({
version: '1.0.0',
});
const map = provider();
expect(map.get('current-key')).toEqual({ current: true });
});
});
describe('capacity limits', () => {
it('should respect maxEntries limit', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['item'],
maxEntries: 3,
});
const map = provider();
// Add more entries than the limit
for (let i = 0; i < 5; i++) {
map.set(`item-${i}`, { index: i });
}
vi.advanceTimersByTime(2500);
const stored = localStorage.getItem('lobechat-swr-cache');
const entries = JSON.parse(stored!);
expect(entries.length).toBeLessThanOrEqual(3);
});
});
describe('debounced saving', () => {
it('should debounce multiple set operations', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['key'],
});
const map = provider();
// Multiple rapid sets
map.set('key-1', { v: 1 });
map.set('key-2', { v: 2 });
map.set('key-3', { v: 3 });
// Before debounce timeout, nothing should be saved
expect(localStorage.getItem('lobechat-swr-cache')).toBeNull();
// After debounce timeout
vi.advanceTimersByTime(2500);
expect(localStorage.getItem('lobechat-swr-cache')).not.toBeNull();
});
});
describe('error handling', () => {
it('should handle corrupted localStorage data', () => {
localStorage.setItem('lobechat-swr-cache', 'invalid-json');
const onError = vi.fn();
const provider = createLocalStorageProvider({ onError });
const map = provider();
expect(map.size).toBe(0);
expect(onError).toHaveBeenCalled();
});
it('should handle QuotaExceededError gracefully', () => {
const provider = createLocalStorageProvider({
cacheablePatterns: ['key'],
});
const map = provider();
// Mock localStorage.setItem to throw QuotaExceededError
const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError');
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw quotaError;
});
map.set('key', { data: 'test' });
vi.advanceTimersByTime(2500);
// Should not throw
expect(true).toBe(true);
});
});
describe('SSR compatibility', () => {
it('should return empty Map when window is undefined', () => {
// This test verifies the SSR check in the implementation
// The actual SSR scenario is tested implicitly by the guard clause
const provider = createLocalStorageProvider();
const map = provider();
expect(map).toBeInstanceOf(Map);
});
});
});
describe('clearSWRCache', () => {
beforeEach(() => {
localStorage.clear();
});
it('should clear the cache from localStorage', () => {
localStorage.setItem('lobechat-swr-cache', JSON.stringify([['key', { data: 'test' }]]));
clearSWRCache();
expect(localStorage.getItem('lobechat-swr-cache')).toBeNull();
});
it('should use custom cache key if provided', () => {
const customKey = 'custom-cache-key';
localStorage.setItem(customKey, JSON.stringify([['key', { data: 'test' }]]));
clearSWRCache(customKey);
expect(localStorage.getItem(customKey)).toBeNull();
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* SWR localStorage Cache Provider
*
* Provides localStorage persistence for selected SWR requests.
* Only keys matching the whitelist patterns will be cached to localStorage.
*
* @example
* ```tsx
* <SWRConfig value={{ provider: createLocalStorageProvider() }}>
* <App />
* </SWRConfig>
* ```
*/
interface CacheEntry<T = unknown> {
/** Cached data */
data: T;
/** Cache timestamp */
timestamp: number;
/** App version */
version: string;
}
export interface LocalStorageCacheOptions {
/** localStorage key name, defaults to 'lobechat-swr-cache' */
cacheKey?: string;
/** Allowed SWR key patterns (whitelist) */
cacheablePatterns?: string[];
/** Maximum cache entries, defaults to 50 */
maxEntries?: number;
/** Error callback */
onError?: (error: Error) => void;
/** Cache TTL in milliseconds, defaults to 24 hours */
ttl?: number;
/** App version, cache is cleared when version changes */
version?: string;
}
/**
* Default cacheable SWR key patterns
* Only requests matching these patterns will be persisted
*/
const DEFAULT_CACHEABLE_PATTERNS: string[] = [
// Add patterns as needed
];
/**
* Check if localStorage is available
*/
const isLocalStorageAvailable = (): boolean => {
if (typeof window === 'undefined') return false;
try {
const testKey = '__swr_cache_test__';
localStorage.setItem(testKey, 'test');
localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
};
/**
* Check if key matches whitelist patterns
*
* SWR keys can be:
* - String: 'fetchSessions'
* - Serialized array: '["fetchSessions","user-123"]'
*
* We check if the key string contains any whitelist pattern
*/
const matchesCacheablePattern = (key: string, patterns: string[]): boolean => {
if (patterns.length === 0) return false;
return patterns.some((pattern) => key.includes(pattern));
};
/**
* Create localStorage cache provider
*
* Features:
* - Whitelist mechanism: only cache specified keys
* - TTL expiration: auto-cleanup expired data
* - Version control: auto-cleanup when app version changes
* - Capacity limit: prevent exceeding localStorage limit
* - SSR compatible: returns empty Map on server
* - Error recovery: fallback to memory cache on error
*/
export function createLocalStorageProvider(options: LocalStorageCacheOptions = {}) {
const {
cacheKey = 'lobechat-swr-cache',
ttl = 24 * 60 * 60 * 1000, // 24 hours
maxEntries = 50,
version = '1.0.0',
cacheablePatterns = DEFAULT_CACHEABLE_PATTERNS,
onError = (error) => console.error('[SWR Cache]', error),
} = options;
// Return memory cache when SSR or localStorage unavailable
if (!isLocalStorageAvailable()) {
return () => new Map();
}
return (): Map<string, unknown> => {
/**
* Load cache from localStorage
*/
const loadCache = (): Map<string, unknown> => {
try {
const stored = localStorage.getItem(cacheKey);
if (!stored) {
return new Map();
}
const entries: [string, CacheEntry][] = JSON.parse(stored);
const now = Date.now();
// Filter: expired data, version mismatch
const validEntries = entries
.filter(([, entry]) => {
const isExpired = now - entry.timestamp > ttl;
const isValidVersion = entry.version === version;
return !isExpired && isValidVersion;
})
.map(([key, entry]) => [key, entry.data] as [string, unknown]);
return new Map(validEntries);
} catch (error) {
onError(error as Error);
return new Map();
}
};
const initialData = loadCache();
// Debounced save variables (outside class to avoid initialization order issues)
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let cacheMapInstance: Map<string, unknown> | null = null;
const saveCache = () => {
if (!cacheMapInstance) return;
try {
// Only save whitelisted keys
const entries = Array.from(cacheMapInstance.entries())
.filter(([key]) => matchesCacheablePattern(key, cacheablePatterns))
.slice(-maxEntries)
.map(([key, data]) => [key, { data, timestamp: Date.now(), version } as CacheEntry]);
const serialized = JSON.stringify(entries);
// Check size, cleanup half when exceeding 4MB
const sizeInMB = new Blob([serialized]).size / (1024 * 1024);
if (sizeInMB > 4) {
const reduced = entries.slice(-Math.floor(maxEntries / 2));
localStorage.setItem(cacheKey, JSON.stringify(reduced));
console.warn(`[SWR Cache] Cache too large (${sizeInMB.toFixed(2)}MB), cleaned up`);
} else {
localStorage.setItem(cacheKey, serialized);
}
} catch (error) {
if ((error as DOMException).name === 'QuotaExceededError') {
// Quota exceeded, clear cache
try {
localStorage.removeItem(cacheKey);
} catch {
// ignore
}
console.error('[SWR Cache] Quota exceeded, cache cleared');
} else {
onError(error as Error);
}
}
};
const debouncedSave = () => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveCache, 2000);
};
// Save immediately on page unload
window.addEventListener('beforeunload', () => {
if (saveTimer) clearTimeout(saveTimer);
saveCache();
});
// Multi-tab sync
window.addEventListener('storage', (event) => {
if (event.key === cacheKey && event.newValue && cacheMapInstance) {
try {
const parsedEntries: [string, CacheEntry][] = JSON.parse(event.newValue);
// Only update whitelisted keys
parsedEntries.forEach(([key, entry]) => {
if (matchesCacheablePattern(key, cacheablePatterns)) {
cacheMapInstance!.set(key, entry.data);
}
});
} catch (error) {
onError(error as Error);
}
}
});
/**
* Create Map with interception
* Only whitelisted keys trigger persistence
*/
class LocalStorageCacheMap extends Map<string, unknown> {
private initialized = false;
constructor(entries?: readonly (readonly [string, unknown])[] | null) {
super();
// Manually add initial data to avoid triggering set in super()
if (entries) {
for (const [key, value] of entries) {
super.set(key, value);
}
}
this.initialized = true;
}
get(key: string): unknown {
return super.get(key);
}
set(key: string, value: unknown): this {
super.set(key, value);
// Only trigger save after initialization and for whitelisted keys
if (this.initialized && matchesCacheablePattern(key, cacheablePatterns)) {
debouncedSave();
}
return this;
}
delete(key: string): boolean {
const result = super.delete(key);
if (this.initialized && matchesCacheablePattern(key, cacheablePatterns)) {
debouncedSave();
}
return result;
}
}
// Create Map instance
const cacheMap = new LocalStorageCacheMap(Array.from(initialData.entries()));
cacheMapInstance = cacheMap;
return cacheMap;
};
}
/**
* Clear SWR localStorage cache
* Can be used for manual cleanup or when app version changes
*/
export function clearSWRCache(cacheKey = 'lobechat-swr-cache'): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(cacheKey);
console.log('[SWR Cache] Cache cleared');
} catch (error) {
console.error('[SWR Cache] Failed to clear cache:', error);
}
}
/**
* SWR localStorage cache whitelist
* Only keys matching these patterns will be persisted to localStorage
*/
const SWR_CACHEABLE_PATTERNS = [
// Home page data
'fetchAgentList', // Agent list
'fetchGroups', // Group list
'fetchRecentTopics', // Recent topics
'fetchRecentResources', // Recent resources
'fetchRecentPages', // Recent pages
// Chat page data
'SWR_USE_FETCH_TOPIC', // Topic list (cached per agentId/groupId)
'fetchGroupDetail', // Group detail (cached per groupId)
];
/**
* Export provider factory function for SWRConfig
*/
export const swrCacheProvider = () => {
return createLocalStorageProvider({
cacheablePatterns: SWR_CACHEABLE_PATTERNS,
ttl: 12 * 60 * 60 * 1000, // 12 hours
});
};
+82
View File
@@ -0,0 +1,82 @@
/**
* useClientDataSWR with automatic Zustand store sync
*
* Solves the problem of SWR cached data not being immediately synced to Zustand store.
* When SWR returns data from localStorage cache, it will automatically sync to store via onData callback.
*/
import { useEffect, useRef } from 'react';
import type { SWRConfiguration, SWRResponse } from 'swr';
import { useClientDataSWR } from './index';
type Key = string | readonly unknown[] | null | undefined;
interface UseClientDataSWRWithSyncOptions<T> extends SWRConfiguration<T> {
/**
* Data sync callback, called when data is available (both cached and fresh data)
* Used to sync data to Zustand store
*/
onData?: (data: T) => void;
/**
* Whether to skip sync (optional, for conditional skipping)
*/
skipSync?: boolean;
}
/**
* Enhanced version of useClientDataSWR with automatic cache data sync to Zustand store
*
* @example
* ```ts
* useClientDataSWRWithSync(
* isLogin ? ['fetchAgentList', isLogin] : null,
* () => homeService.getSidebarAgentList(),
* {
* onData: (data) => {
* // Auto sync to store, whether cached or fresh data
* set({ ...mapResponseToState(data), isInit: true });
* },
* skipSync: state.isInit, // Optional: skip after initialized
* }
* );
* ```
*/
export function useClientDataSWRWithSync<T>(
key: Key,
fetcher: (() => Promise<T>) | null,
options?: UseClientDataSWRWithSyncOptions<T>,
): SWRResponse<T> {
const { onData, skipSync, onSuccess, ...swrOptions } = options || {};
const hasSyncedRef = useRef(false);
const response = useClientDataSWR<T>(key, fetcher, {
...swrOptions,
onSuccess: (data, key, config) => {
// Call original onSuccess
onSuccess?.(data, key, config);
// Also sync via onData
if (onData && !skipSync) {
onData(data);
hasSyncedRef.current = true;
}
},
});
const { data } = response;
// When cached data is available, sync to store immediately
useEffect(() => {
if (data && onData && !skipSync && !hasSyncedRef.current) {
onData(data);
hasSyncedRef.current = true;
}
}, [data, onData, skipSync]);
// Reset sync state when key changes
useEffect(() => {
hasSyncedRef.current = false;
}, [key?.toString()]);
return response;
}
+10 -10
View File
@@ -5,7 +5,7 @@ import { mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import type { ChatGroupItem } from '@/database/schemas/chatGroup';
import { useClientDataSWR } from '@/libs/swr';
import { useClientDataSWRWithSync } from '@/libs/swr';
import { chatGroupService } from '@/services/chatGroup';
import { getAgentStoreState } from '@/store/agent';
import { ChatGroupStore } from '@/store/agentGroup/store';
@@ -156,15 +156,15 @@ const chatGroupInternalSlice: StateCreator<
},
useFetchGroupDetail: (enabled, groupId) =>
useClientDataSWR<AgentGroupDetail | null>(
useClientDataSWRWithSync<AgentGroupDetail | null>(
enabled && groupId ? [FETCH_GROUP_DETAIL_KEY, groupId] : null,
async ([, id]) => {
const groupDetail = await chatGroupService.getGroupDetail(id as string);
if (!groupDetail) throw new Error(`Group ${id} not found`);
async () => {
const groupDetail = await chatGroupService.getGroupDetail(groupId);
if (!groupDetail) throw new Error(`Group ${groupId} not found`);
return groupDetail;
},
{
onSuccess: (groupDetail) => {
onData: (groupDetail) => {
if (!groupDetail) return;
// Update groupMap with detailed group info including agents
@@ -181,7 +181,7 @@ const chatGroupInternalSlice: StateCreator<
groupMap: nextGroupMap,
},
false,
n('useFetchGroupDetail/onSuccess', { groupId: groupDetail.id }),
n('useFetchGroupDetail/onData', { groupId: groupDetail.id }),
);
// Sync group agents to agentStore for builtin agent resolution (e.g., supervisor slug)
@@ -207,12 +207,12 @@ const chatGroupInternalSlice: StateCreator<
// SWR Hooks for data fetching
// This is not used for now, as we are combining group in the session lambda's response
useFetchGroups: (enabled, isLogin) =>
useClientDataSWR<ChatGroupItem[]>(
useClientDataSWRWithSync<ChatGroupItem[]>(
enabled ? [FETCH_GROUPS_KEY, isLogin] : null,
async () => chatGroupService.getGroups(),
{
fallbackData: [],
onSuccess: (groups) => {
onData: (groups) => {
// Update both groups list and groupMap
const currentMap = get().groupMap;
const nextGroupMap = groups.reduce(
@@ -237,7 +237,7 @@ const chatGroupInternalSlice: StateCreator<
groupsInit: true,
},
false,
n('useFetchGroups/onSuccess'),
n('useFetchGroups/onData'),
);
},
suspense: true,
+10 -11
View File
@@ -10,7 +10,7 @@ import { StateCreator } from 'zustand/vanilla';
import { message } from '@/components/AntdStaticMethods';
import { LOADING_FLAT } from '@/const/message';
import { useClientDataSWR } from '@/libs/swr';
import { useClientDataSWRWithSync } from '@/libs/swr';
import { chatService } from '@/services/chat';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
@@ -274,16 +274,15 @@ export const chatTopic: StateCreator<
const containerKey = topicMapKey({ agentId, groupId });
const hasValidContainer = !!(groupId || agentId);
return useClientDataSWR<{ items: ChatTopic[]; total: number }>(
return useClientDataSWRWithSync<{ items: ChatTopic[]; total: number }>(
enable && hasValidContainer
? [SWR_USE_FETCH_TOPIC, containerKey, { isInbox, pageSize }]
: null,
async ([, key, params]: [string, string, { isInbox?: boolean; pageSize: number }]) => {
const { isInbox, pageSize } = params;
// agentId and groupId come from the outer scope closure
async () => {
// agentId, groupId, isInbox, pageSize come from the outer scope closure
if (!agentId && !groupId) return { items: [], total: 0 };
const currentData = get().topicDataMap[key];
const currentData = get().topicDataMap[containerKey];
const lastPageSize = currentData?.pageSize;
const hasExistingItems = (currentData?.items?.length || 0) > 0;
@@ -292,7 +291,7 @@ export const chatTopic: StateCreator<
const isExpanding =
hasExistingItems && typeof lastPageSize === 'number' && pageSize > lastPageSize;
if (isExpanding) {
get().internal_updateTopicData(key, { isExpandingPageSize: true });
get().internal_updateTopicData(containerKey, { isExpandingPageSize: true });
}
const result = await topicService.getTopics({
@@ -305,14 +304,14 @@ export const chatTopic: StateCreator<
// Reset expanding state after fetch completes
if (isExpanding) {
get().internal_updateTopicData(key, { isExpandingPageSize: false });
get().internal_updateTopicData(containerKey, { isExpandingPageSize: false });
}
return result;
},
{
// onSuccess: responsible for state updates
onSuccess: async (result) => {
// onData: responsible for state updates (fires for both cached and fresh data)
onData: (result) => {
if (!hasValidContainer) return;
const { items: topics, total: totalCount } = result;
@@ -338,7 +337,7 @@ export const chatTopic: StateCreator<
},
},
false,
n('useFetchTopics(success)', { containerKey }),
n('useFetchTopics(onData)', { containerKey }),
);
},
},
+4 -4
View File
@@ -4,7 +4,7 @@ import { mutate } from 'swr';
import type { StateCreator } from 'zustand/vanilla';
import type { SidebarAgentItem, SidebarAgentListResponse } from '@/database/repositories/home';
import { useClientDataSWR } from '@/libs/swr';
import { useClientDataSWR, useClientDataSWRWithSync } from '@/libs/swr';
import { homeService } from '@/services/home';
import type { HomeStore } from '@/store/home/store';
import { setNamespace } from '@/utils/storeDebug';
@@ -58,11 +58,11 @@ export const createAgentListSlice: StateCreator<
},
useFetchAgentList: (isLogin) =>
useClientDataSWR<SidebarAgentListResponse>(
useClientDataSWRWithSync<SidebarAgentListResponse>(
isLogin === true ? [FETCH_AGENT_LIST_KEY, isLogin] : null,
() => homeService.getSidebarAgentList(),
{
onSuccess: (data) => {
onData: (data) => {
const state = get();
const newState = mapResponseToState(data);
@@ -82,7 +82,7 @@ export const createAgentListSlice: StateCreator<
isAgentListInit: true,
},
false,
n('useFetchAgentList/onSuccess'),
n('useFetchAgentList/onData'),
);
},
},
+10 -10
View File
@@ -2,7 +2,7 @@ import isEqual from 'fast-deep-equal';
import type { SWRResponse } from 'swr';
import type { StateCreator } from 'zustand/vanilla';
import { useClientDataSWR } from '@/libs/swr';
import { useClientDataSWRWithSync } from '@/libs/swr';
import { fileService } from '@/services/file';
import { topicService } from '@/services/topic';
import type { HomeStore } from '@/store/home/store';
@@ -29,54 +29,54 @@ export const createRecentSlice: StateCreator<
RecentAction
> = (set, get) => ({
useFetchRecentPages: (isLogin) =>
useClientDataSWR<any[]>(
useClientDataSWRWithSync<any[]>(
// Only fetch when login status is explicitly true (not null/undefined)
isLogin === true ? [FETCH_RECENT_PAGES_KEY, isLogin] : null,
async () => fileService.getRecentPages(12),
{
onSuccess: (data) => {
onData: (data) => {
if (get().isRecentPagesInit && isEqual(get().recentPages, data)) return;
set(
{ isRecentPagesInit: true, recentPages: data },
false,
n('useFetchRecentPages/onSuccess'),
n('useFetchRecentPages/onData'),
);
},
},
),
useFetchRecentResources: (isLogin) =>
useClientDataSWR<FileListItem[]>(
useClientDataSWRWithSync<FileListItem[]>(
// Only fetch when login status is explicitly true (not null/undefined)
isLogin === true ? [FETCH_RECENT_RESOURCES_KEY, isLogin] : null,
async () => fileService.getRecentFiles(12),
{
onSuccess: (data) => {
onData: (data) => {
if (get().isRecentResourcesInit && isEqual(get().recentResources, data)) return;
set(
{ isRecentResourcesInit: true, recentResources: data },
false,
n('useFetchRecentResources/onSuccess'),
n('useFetchRecentResources/onData'),
);
},
},
),
useFetchRecentTopics: (isLogin) =>
useClientDataSWR<RecentTopic[]>(
useClientDataSWRWithSync<RecentTopic[]>(
// Only fetch when login status is explicitly true (not null/undefined)
isLogin === true ? [FETCH_RECENT_TOPICS_KEY, isLogin] : null,
async () => topicService.getRecentTopics(12),
{
onSuccess: (data) => {
onData: (data) => {
if (get().isRecentTopicsInit && isEqual(get().recentTopics, data)) return;
set(
{ isRecentTopicsInit: true, recentTopics: data },
false,
n('useFetchRecentTopics/onSuccess'),
n('useFetchRecentTopics/onData'),
);
},
},