perf: optimize load of pagination items with caching

This commit is contained in:
Divlo 2022-08-24 17:22:55 +02:00
parent 19fc29ad47
commit ad64f1c571
No known key found for this signature in database
GPG Key ID: 8F9478F220CE65E9
16 changed files with 168 additions and 65 deletions

View File

@ -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])

View File

@ -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

View File

@ -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(() => {})
}

View File

@ -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()

View File

@ -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])

View File

@ -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])

View File

@ -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()

View File

@ -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()

View File

@ -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 }
}

View File

@ -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',

View File

@ -28,6 +28,9 @@ const Application = ({ Component, pageProps }: AppProps): JSX.Element => {
}
window.addEventListener('resize', appHeight)
appHeight()
return () => {
window.removeEventListener('resize', appHeight)
}
}, [lang])
return (

View File

@ -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'
}

View File

@ -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
}, [])

View File

@ -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
View 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))
}

View File

@ -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
})
}