perf: optimize load of pagination items with caching
This commit is contained in:
		| @@ -22,12 +22,15 @@ export const JoinGuildsPublic: React.FC = () => { | ||||
|     }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on('guilds', (data: SocketData<GuildPublicType>) => { | ||||
|       handleSocketData({ data, setItems }) | ||||
|     }) | ||||
|     authentication?.socket?.on( | ||||
|       'guilds', | ||||
|       (data: SocketData<GuildPublicType>) => { | ||||
|         handleSocketData({ data, setItems }) | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('guilds') | ||||
|       authentication?.socket?.off('guilds') | ||||
|     } | ||||
|   }, [authentication.socket, setItems]) | ||||
|  | ||||
|   | ||||
| @@ -72,7 +72,7 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => { | ||||
|     } else { | ||||
|       try { | ||||
|         const { data } = await api.post<Tokens>('/users/signin', formData) | ||||
|         const authentication = new AuthenticationClass(data) | ||||
|         const authentication = new AuthenticationClass(data, true) | ||||
|         authentication.signin() | ||||
|         await router.push('/application') | ||||
|         return null | ||||
|   | ||||
| @@ -24,7 +24,7 @@ export const AuthenticationSocialMedia: React.FC = () => { | ||||
|   useEffect(() => { | ||||
|     const data = router.query | ||||
|     if (isTokens(data)) { | ||||
|       const authentication = new Authentication(data) | ||||
|       const authentication = new Authentication(data, true) | ||||
|       authentication.signin() | ||||
|       router.push('/application').catch(() => {}) | ||||
|     } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { useAuthentication } from '../tools/authentication' | ||||
| import { Channel, ChannelWithDefaultChannelId } from '../models/Channel' | ||||
| import { GuildsChannelsPath } from '../components/Application' | ||||
| import { handleSocketData, SocketData } from '../tools/handleSocketData' | ||||
| import { CacheKey, CHANNELS_CACHE_KEY } from '../tools/cache' | ||||
|  | ||||
| export interface Channels { | ||||
|   channels: Channel[] | ||||
| @@ -27,6 +28,8 @@ export const ChannelsProvider: React.FC< | ||||
|   const router = useRouter() | ||||
|   const { authentication } = useAuthentication() | ||||
|  | ||||
|   const cacheKey: CacheKey = `${path.guildId}-${CHANNELS_CACHE_KEY}` | ||||
|  | ||||
|   const { | ||||
|     items: channels, | ||||
|     hasMore, | ||||
| @@ -35,14 +38,15 @@ export const ChannelsProvider: React.FC< | ||||
|     setItems | ||||
|   } = usePagination<Channel>({ | ||||
|     api: authentication.api, | ||||
|     url: `/guilds/${path.guildId}/channels` | ||||
|     url: `/guilds/${path.guildId}/channels`, | ||||
|     cacheKey | ||||
|   }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on( | ||||
|     authentication?.socket?.on( | ||||
|       'channels', | ||||
|       async (data: SocketData<ChannelWithDefaultChannelId>) => { | ||||
|         handleSocketData({ data, setItems }) | ||||
|         handleSocketData({ data, setItems, cacheKey }) | ||||
|         if (data.action === 'delete') { | ||||
|           await router.push( | ||||
|             `/application/${path.guildId}/${data.item.defaultChannelId}` | ||||
| @@ -52,9 +56,9 @@ export const ChannelsProvider: React.FC< | ||||
|     ) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('channels') | ||||
|       authentication?.socket?.off('channels') | ||||
|     } | ||||
|   }, [authentication.socket, path.guildId, router, setItems]) | ||||
|   }, [authentication.socket, path.guildId, router, setItems, cacheKey]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     resetPagination() | ||||
|   | ||||
| @@ -47,7 +47,7 @@ export const GuildMemberProvider: React.FC< | ||||
|   }, [path, authentication.api]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on( | ||||
|     authentication?.socket?.on( | ||||
|       'guilds', | ||||
|       async (data: SocketData<GuildWithDefaultChannelId>) => { | ||||
|         if (data.item.id === path.guildId) { | ||||
| @@ -72,7 +72,7 @@ export const GuildMemberProvider: React.FC< | ||||
|     ) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('guilds') | ||||
|       authentication?.socket?.off('guilds') | ||||
|     } | ||||
|   }, [authentication.socket, path.guildId, router]) | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { NextPage, usePagination } from '../hooks/usePagination' | ||||
| import { useAuthentication } from '../tools/authentication' | ||||
| import { GuildWithDefaultChannelId } from '../models/Guild' | ||||
| import { handleSocketData, SocketData } from '../tools/handleSocketData' | ||||
| import { GUILDS_CACHE_KEY } from '../tools/cache' | ||||
|  | ||||
| export interface Guilds { | ||||
|   guilds: GuildWithDefaultChannelId[] | ||||
| @@ -29,19 +30,20 @@ export const GuildsProvider: React.FC<React.PropsWithChildren<{}>> = ( | ||||
|     setItems | ||||
|   } = usePagination<GuildWithDefaultChannelId>({ | ||||
|     api: authentication.api, | ||||
|     url: '/guilds' | ||||
|     url: '/guilds', | ||||
|     cacheKey: GUILDS_CACHE_KEY | ||||
|   }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on( | ||||
|     authentication?.socket?.on( | ||||
|       'guilds', | ||||
|       (data: SocketData<GuildWithDefaultChannelId>) => { | ||||
|         handleSocketData({ data, setItems }) | ||||
|         handleSocketData({ data, setItems, cacheKey: GUILDS_CACHE_KEY }) | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('guilds') | ||||
|       authentication?.socket?.off('guilds') | ||||
|     } | ||||
|   }, [authentication.socket, setItems]) | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { MemberWithPublicUser } from '../models/Member' | ||||
| import { GuildsChannelsPath } from '../components/Application' | ||||
| import { handleSocketData, SocketData } from '../tools/handleSocketData' | ||||
| import { User } from '../models/User' | ||||
| import { CacheKey, MEMBERS_CACHE_KEY } from '../tools/cache' | ||||
|  | ||||
| export interface Members { | ||||
|   members: MemberWithPublicUser[] | ||||
| @@ -27,6 +28,8 @@ export const MembersProviders: React.FC< | ||||
|  | ||||
|   const { authentication } = useAuthentication() | ||||
|  | ||||
|   const cacheKey: CacheKey = `${path.guildId}-${MEMBERS_CACHE_KEY}` | ||||
|  | ||||
|   const { | ||||
|     items: members, | ||||
|     hasMore, | ||||
| @@ -35,18 +38,19 @@ export const MembersProviders: React.FC< | ||||
|     setItems | ||||
|   } = usePagination<MemberWithPublicUser>({ | ||||
|     api: authentication.api, | ||||
|     url: `/guilds/${path.guildId}/members` | ||||
|     url: `/guilds/${path.guildId}/members`, | ||||
|     cacheKey | ||||
|   }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on( | ||||
|     authentication?.socket?.on( | ||||
|       'members', | ||||
|       (data: SocketData<MemberWithPublicUser>) => { | ||||
|         handleSocketData({ data, setItems }) | ||||
|         handleSocketData({ data, setItems, cacheKey }) | ||||
|       } | ||||
|     ) | ||||
|  | ||||
|     authentication.socket.on('users', (data: SocketData<User>) => { | ||||
|     authentication?.socket?.on('users', (data: SocketData<User>) => { | ||||
|       setItems((oldItems) => { | ||||
|         const newItems = [...oldItems] | ||||
|         switch (data.action) { | ||||
| @@ -65,10 +69,10 @@ export const MembersProviders: React.FC< | ||||
|     }) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('members') | ||||
|       authentication.socket.off('users') | ||||
|       authentication?.socket?.off('members') | ||||
|       authentication?.socket?.off('users') | ||||
|     } | ||||
|   }, [authentication.socket, setItems]) | ||||
|   }, [authentication.socket, setItems, cacheKey]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     resetPagination() | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { useAuthentication } from '../tools/authentication' | ||||
| import { MessageWithMember } from '../models/Message' | ||||
| import { GuildsChannelsPath } from '../components/Application' | ||||
| import { handleSocketData, SocketData } from '../tools/handleSocketData' | ||||
| import { CacheKey, MESSAGES_CACHE_KEY } from '../tools/cache' | ||||
|  | ||||
| export interface Messages { | ||||
|   messages: MessageWithMember[] | ||||
| @@ -25,6 +26,8 @@ export const MessagesProvider: React.FC< | ||||
|   const { path, children } = props | ||||
|   const { authentication, user } = useAuthentication() | ||||
|  | ||||
|   const cacheKey: CacheKey = `${path.channelId}-${MESSAGES_CACHE_KEY}` | ||||
|  | ||||
|   const { | ||||
|     items: messages, | ||||
|     hasMore, | ||||
| @@ -34,11 +37,12 @@ export const MessagesProvider: React.FC< | ||||
|   } = usePagination<MessageWithMember>({ | ||||
|     api: authentication.api, | ||||
|     url: `/channels/${path.channelId}/messages`, | ||||
|     inverse: true | ||||
|     inverse: true, | ||||
|     cacheKey | ||||
|   }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     authentication.socket.on( | ||||
|     authentication?.socket?.on( | ||||
|       'messages', | ||||
|       (data: SocketData<MessageWithMember>) => { | ||||
|         if (data.item.channelId === path.channelId) { | ||||
| @@ -48,7 +52,7 @@ export const MessagesProvider: React.FC< | ||||
|           const isAtBottom = | ||||
|             messagesDiv.scrollHeight - messagesDiv.scrollTop <= | ||||
|             messagesDiv.clientHeight | ||||
|           handleSocketData({ data, setItems }) | ||||
|           handleSocketData({ data, setItems, cacheKey }) | ||||
|           if ( | ||||
|             data.action === 'create' && | ||||
|             (isAtBottom || data.item.member.userId === user.id) | ||||
| @@ -60,9 +64,9 @@ export const MessagesProvider: React.FC< | ||||
|     ) | ||||
|  | ||||
|     return () => { | ||||
|       authentication.socket.off('messages') | ||||
|       authentication?.socket?.off('messages') | ||||
|     } | ||||
|   }, [authentication.socket, setItems, path, user.id]) | ||||
|   }, [authentication.socket, setItems, path, user.id, cacheKey]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     resetPagination() | ||||
|   | ||||
| @@ -2,6 +2,11 @@ import { useState, useRef, useCallback } from 'react' | ||||
| import { AxiosInstance } from 'axios' | ||||
|  | ||||
| import { FetchState } from './useFetchState' | ||||
| import { | ||||
|   CacheKey, | ||||
|   getPaginationCache, | ||||
|   savePaginationCache | ||||
| } from '../tools/cache' | ||||
|  | ||||
| export interface Query { | ||||
|   [key: string]: string | ||||
| @@ -13,6 +18,11 @@ export interface UsePaginationOptions { | ||||
|   api: AxiosInstance | ||||
|   url: string | ||||
|   inverse?: boolean | ||||
|   cacheKey?: CacheKey | ||||
| } | ||||
|  | ||||
| export interface PaginationItem { | ||||
|   id: number | ||||
| } | ||||
|  | ||||
| export type SetItems<T> = React.Dispatch<React.SetStateAction<T[]>> | ||||
| @@ -25,16 +35,25 @@ export interface UsePaginationResult<T> { | ||||
|   setItems: SetItems<T> | ||||
| } | ||||
|  | ||||
| export const usePagination = <T extends { id: number }>( | ||||
| export const usePagination = <T extends PaginationItem>( | ||||
|   options: UsePaginationOptions | ||||
| ): UsePaginationResult<T> => { | ||||
|   const { api, url, inverse = false } = options | ||||
|   const { api, url, inverse = false, cacheKey } = options | ||||
|  | ||||
|   const [items, setItems] = useState<T[]>([]) | ||||
|   const [hasMore, setHasMore] = useState(true) | ||||
|   const fetchState = useRef<FetchState>('idle') | ||||
|   const afterId = useRef<number | null>(null) | ||||
|  | ||||
|   const updateAfterId = (newItems: T[]): void => { | ||||
|     if (!inverse) { | ||||
|       afterId.current = | ||||
|         newItems.length > 0 ? newItems[newItems.length - 1].id : null | ||||
|     } else { | ||||
|       afterId.current = newItems.length > 0 ? newItems[0].id : null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const nextPageAsync: NextPageAsync = useCallback( | ||||
|     async (query) => { | ||||
|       if (fetchState.current !== 'idle') { | ||||
| @@ -49,19 +68,22 @@ export const usePagination = <T extends { id: number }>( | ||||
|       const { data: newItems } = await api.get<T[]>( | ||||
|         `${url}?${searchParameters.toString()}` | ||||
|       ) | ||||
|       if (!inverse) { | ||||
|         afterId.current = | ||||
|           newItems.length > 0 ? newItems[newItems.length - 1].id : null | ||||
|       } else { | ||||
|         afterId.current = newItems.length > 0 ? newItems[0].id : null | ||||
|       } | ||||
|       updateAfterId(newItems) | ||||
|       setItems((oldItems) => { | ||||
|         return inverse ? [...newItems, ...oldItems] : [...oldItems, ...newItems] | ||||
|         const updatedItems = inverse | ||||
|           ? [...newItems, ...oldItems] | ||||
|           : [...oldItems, ...newItems] | ||||
|         if (cacheKey != null) { | ||||
|           savePaginationCache(cacheKey, updatedItems) | ||||
|         } | ||||
|         return updatedItems | ||||
|       }) | ||||
|       setHasMore(newItems.length > 0) | ||||
|       fetchState.current = 'idle' | ||||
|     }, | ||||
|     [api, url, inverse] | ||||
|  | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps -- We don't want infinite loops with updateAfterId | ||||
|     [api, url, inverse, cacheKey] | ||||
|   ) | ||||
|  | ||||
|   const nextPage: NextPage = useCallback( | ||||
| @@ -80,9 +102,17 @@ export const usePagination = <T extends { id: number }>( | ||||
|   ) | ||||
|  | ||||
|   const resetPagination = useCallback((): void => { | ||||
|     afterId.current = null | ||||
|     setItems([]) | ||||
|   }, []) | ||||
|     if (cacheKey == null) { | ||||
|       afterId.current = null | ||||
|       setItems([]) | ||||
|     } else { | ||||
|       const newItems = getPaginationCache<T>(cacheKey) | ||||
|       setItems(newItems) | ||||
|       updateAfterId(newItems) | ||||
|     } | ||||
|  | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps -- We don't want infinite loops with updateAfterId | ||||
|   }, [cacheKey]) | ||||
|  | ||||
|   return { items, hasMore, nextPage, resetPagination, setItems } | ||||
| } | ||||
|   | ||||
| @@ -7,7 +7,7 @@ const nextTranslate = require('next-translate') | ||||
| /** @type {import("next").NextConfig} */ | ||||
| module.exports = nextTranslate( | ||||
|   nextPWA({ | ||||
|     reactStrictMode: true, | ||||
|     reactStrictMode: false, | ||||
|     images: { | ||||
|       domains: [ | ||||
|         'api.thream.divlo.fr', | ||||
|   | ||||
| @@ -28,6 +28,9 @@ const Application = ({ Component, pageProps }: AppProps): JSX.Element => { | ||||
|     } | ||||
|     window.addEventListener('resize', appHeight) | ||||
|     appHeight() | ||||
|     return () => { | ||||
|       window.removeEventListener('resize', appHeight) | ||||
|     } | ||||
|   }, [lang]) | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -9,27 +9,36 @@ import { fetchRefreshToken } from './authenticationFromServerSide' | ||||
| export class Authentication { | ||||
|   public tokens: Tokens | ||||
|   public accessTokenAge: number | ||||
|   public socket: Socket | ||||
|   public socket?: Socket | ||||
|   public api: AxiosInstance | ||||
|  | ||||
|   constructor(tokens: Tokens) { | ||||
|   constructor(tokens: Tokens, disableSocketIO: boolean = false) { | ||||
|     this.tokens = tokens | ||||
|     this.accessTokenAge = Date.now() | ||||
|     this.socket = io(API_URL, { | ||||
|       auth: { token: `Bearer ${tokens.accessToken}` } | ||||
|     }) | ||||
|     this.socket.on('connect_error', (error) => { | ||||
|       if (error.message.startsWith('Unauthorized')) { | ||||
|         fetchRefreshToken(this.tokens.refreshToken) | ||||
|           .then(({ accessToken }) => { | ||||
|             this.setAccessToken(accessToken) | ||||
|           }) | ||||
|           .catch(async () => { | ||||
|             this.signout() | ||||
|             return await Promise.reject(error) | ||||
|           }) | ||||
|       } | ||||
|     }) | ||||
|     if (disableSocketIO || typeof window === 'undefined') { | ||||
|       this.socket = undefined | ||||
|     } else { | ||||
|       this.socket = io(API_URL, { | ||||
|         auth: { token: `Bearer ${tokens.accessToken}` } | ||||
|       }) | ||||
|       this.socket.on('connect', () => { | ||||
|         console.log( | ||||
|           `Connected to socket with clientId: ${this.socket?.id ?? 'undefined'}` | ||||
|         ) | ||||
|       }) | ||||
|       this.socket.on('connect_error', (error) => { | ||||
|         if (error.message.startsWith('Unauthorized')) { | ||||
|           fetchRefreshToken(this.tokens.refreshToken) | ||||
|             .then(({ accessToken }) => { | ||||
|               this.setAccessToken(accessToken) | ||||
|             }) | ||||
|             .catch(async () => { | ||||
|               this.signout() | ||||
|               return await Promise.reject(error) | ||||
|             }) | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|     this.api = axios.create({ | ||||
|       baseURL: API_URL, | ||||
|       headers: { | ||||
| @@ -83,13 +92,14 @@ export class Authentication { | ||||
|     this.tokens.accessToken = accessToken | ||||
|     this.accessTokenAge = Date.now() | ||||
|     const token = `${this.tokens.type} ${this.tokens.accessToken}` | ||||
|     if (typeof this.socket.auth !== 'function') { | ||||
|     if (typeof this?.socket?.auth !== 'function' && this.socket != null) { | ||||
|       this.socket.auth.token = token | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public signout(): void { | ||||
|     cookies.remove('refreshToken') | ||||
|     window.localStorage.clear() | ||||
|     window.location.href = '/authentication/signin' | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -25,7 +25,8 @@ export const AuthenticationProvider: React.FC< | ||||
|   const [user, setUser] = useState<UserCurrent>(props.authentication.user) | ||||
|  | ||||
|   const authentication = useMemo(() => { | ||||
|     return new Authentication(props.authentication.tokens) | ||||
|     const disableSocketIO = typeof window === 'undefined' | ||||
|     return new Authentication(props.authentication.tokens, disableSocketIO) | ||||
|  | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this memo once | ||||
|   }, []) | ||||
| @@ -33,7 +34,9 @@ export const AuthenticationProvider: React.FC< | ||||
|   useEffect(() => { | ||||
|     setLanguage(props.authentication.user.settings.language).catch(() => {}) | ||||
|     setTheme(props.authentication.user.settings.theme) | ||||
|  | ||||
|     return () => { | ||||
|       authentication?.socket?.disconnect() | ||||
|     } | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this effect once | ||||
|   }, []) | ||||
|  | ||||
|   | ||||
| @@ -71,7 +71,7 @@ export const authenticationFromServerSide = ( | ||||
|       } else { | ||||
|         try { | ||||
|           let data: any = {} | ||||
|           const authentication = new Authentication(tokens) | ||||
|           const authentication = new Authentication(tokens, true) | ||||
|           const { data: currentUser } = await authentication.api.get< | ||||
|             unknown, | ||||
|             AxiosResponse<UserCurrent> | ||||
|   | ||||
							
								
								
									
										34
									
								
								tools/cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								tools/cache.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import { PaginationItem } from '../hooks/usePagination' | ||||
|  | ||||
| export const GUILDS_CACHE_KEY = 'guilds' as const | ||||
| export const CHANNELS_CACHE_KEY = 'channels' as const | ||||
| export const MEMBERS_CACHE_KEY = 'members' as const | ||||
| export const MESSAGES_CACHE_KEY = 'messages' as const | ||||
|  | ||||
| export type CacheKey = | ||||
|   | typeof GUILDS_CACHE_KEY | ||||
|   | `${number}-${typeof CHANNELS_CACHE_KEY}` | ||||
|   | `${number}-${typeof MEMBERS_CACHE_KEY}` | ||||
|   | `${number}-${typeof MESSAGES_CACHE_KEY}` | ||||
|  | ||||
| export const getPaginationCache = <T extends PaginationItem>( | ||||
|   key: CacheKey | ||||
| ): T[] => { | ||||
|   const cache = localStorage.getItem(key) | ||||
|   if (cache != null) { | ||||
|     try { | ||||
|       const data = JSON.parse(cache) | ||||
|       if (Array.isArray(data)) { | ||||
|         return data | ||||
|       } | ||||
|     } catch {} | ||||
|   } | ||||
|   return [] | ||||
| } | ||||
|  | ||||
| export const savePaginationCache = <T extends PaginationItem>( | ||||
|   key: CacheKey, | ||||
|   data: T[] | ||||
| ): void => { | ||||
|   localStorage.setItem(key, JSON.stringify(data)) | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { SetItems } from '../hooks/usePagination' | ||||
| import { CacheKey, savePaginationCache } from './cache' | ||||
|  | ||||
| export interface Item { | ||||
|   id: number | ||||
| @@ -13,6 +14,7 @@ export interface SocketData<T extends Item = Item> { | ||||
| export interface HandleSocketDataOptions<T extends Item = Item> { | ||||
|   setItems: SetItems<T> | ||||
|   data: SocketData<T> | ||||
|   cacheKey?: CacheKey | ||||
| } | ||||
|  | ||||
| export type SocketListener = (data: SocketData) => void | ||||
| @@ -20,7 +22,8 @@ export type SocketListener = (data: SocketData) => void | ||||
| export const handleSocketData = <T extends Item = Item>( | ||||
|   options: HandleSocketDataOptions<T> | ||||
| ): void => { | ||||
|   const { data, setItems } = options | ||||
|   const { data, setItems, cacheKey } = options | ||||
|   console.log('socket.io data received: ', data) | ||||
|  | ||||
|   setItems((oldItems) => { | ||||
|     const newItems = [...oldItems] | ||||
| @@ -47,6 +50,9 @@ export const handleSocketData = <T extends Item = Item>( | ||||
|         break | ||||
|       } | ||||
|     } | ||||
|     if (cacheKey != null) { | ||||
|       savePaginationCache(cacheKey, newItems) | ||||
|     } | ||||
|     return newItems | ||||
|   }) | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user