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(() => { useEffect(() => {
authentication.socket.on('guilds', (data: SocketData<GuildPublicType>) => { authentication?.socket?.on(
'guilds',
(data: SocketData<GuildPublicType>) => {
handleSocketData({ data, setItems }) handleSocketData({ data, setItems })
}) }
)
return () => { return () => {
authentication.socket.off('guilds') authentication?.socket?.off('guilds')
} }
}, [authentication.socket, setItems]) }, [authentication.socket, setItems])

View File

@ -72,7 +72,7 @@ export const Authentication: React.FC<AuthenticationProps> = (props) => {
} else { } else {
try { try {
const { data } = await api.post<Tokens>('/users/signin', formData) const { data } = await api.post<Tokens>('/users/signin', formData)
const authentication = new AuthenticationClass(data) const authentication = new AuthenticationClass(data, true)
authentication.signin() authentication.signin()
await router.push('/application') await router.push('/application')
return null return null

View File

@ -24,7 +24,7 @@ export const AuthenticationSocialMedia: React.FC = () => {
useEffect(() => { useEffect(() => {
const data = router.query const data = router.query
if (isTokens(data)) { if (isTokens(data)) {
const authentication = new Authentication(data) const authentication = new Authentication(data, true)
authentication.signin() authentication.signin()
router.push('/application').catch(() => {}) router.push('/application').catch(() => {})
} }

View File

@ -6,6 +6,7 @@ import { useAuthentication } from '../tools/authentication'
import { Channel, ChannelWithDefaultChannelId } from '../models/Channel' import { Channel, ChannelWithDefaultChannelId } from '../models/Channel'
import { GuildsChannelsPath } from '../components/Application' import { GuildsChannelsPath } from '../components/Application'
import { handleSocketData, SocketData } from '../tools/handleSocketData' import { handleSocketData, SocketData } from '../tools/handleSocketData'
import { CacheKey, CHANNELS_CACHE_KEY } from '../tools/cache'
export interface Channels { export interface Channels {
channels: Channel[] channels: Channel[]
@ -27,6 +28,8 @@ export const ChannelsProvider: React.FC<
const router = useRouter() const router = useRouter()
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const cacheKey: CacheKey = `${path.guildId}-${CHANNELS_CACHE_KEY}`
const { const {
items: channels, items: channels,
hasMore, hasMore,
@ -35,14 +38,15 @@ export const ChannelsProvider: React.FC<
setItems setItems
} = usePagination<Channel>({ } = usePagination<Channel>({
api: authentication.api, api: authentication.api,
url: `/guilds/${path.guildId}/channels` url: `/guilds/${path.guildId}/channels`,
cacheKey
}) })
useEffect(() => { useEffect(() => {
authentication.socket.on( authentication?.socket?.on(
'channels', 'channels',
async (data: SocketData<ChannelWithDefaultChannelId>) => { async (data: SocketData<ChannelWithDefaultChannelId>) => {
handleSocketData({ data, setItems }) handleSocketData({ data, setItems, cacheKey })
if (data.action === 'delete') { if (data.action === 'delete') {
await router.push( await router.push(
`/application/${path.guildId}/${data.item.defaultChannelId}` `/application/${path.guildId}/${data.item.defaultChannelId}`
@ -52,9 +56,9 @@ export const ChannelsProvider: React.FC<
) )
return () => { return () => {
authentication.socket.off('channels') authentication?.socket?.off('channels')
} }
}, [authentication.socket, path.guildId, router, setItems]) }, [authentication.socket, path.guildId, router, setItems, cacheKey])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()

View File

@ -47,7 +47,7 @@ export const GuildMemberProvider: React.FC<
}, [path, authentication.api]) }, [path, authentication.api])
useEffect(() => { useEffect(() => {
authentication.socket.on( authentication?.socket?.on(
'guilds', 'guilds',
async (data: SocketData<GuildWithDefaultChannelId>) => { async (data: SocketData<GuildWithDefaultChannelId>) => {
if (data.item.id === path.guildId) { if (data.item.id === path.guildId) {
@ -72,7 +72,7 @@ export const GuildMemberProvider: React.FC<
) )
return () => { return () => {
authentication.socket.off('guilds') authentication?.socket?.off('guilds')
} }
}, [authentication.socket, path.guildId, router]) }, [authentication.socket, path.guildId, router])

View File

@ -4,6 +4,7 @@ import { NextPage, usePagination } from '../hooks/usePagination'
import { useAuthentication } from '../tools/authentication' import { useAuthentication } from '../tools/authentication'
import { GuildWithDefaultChannelId } from '../models/Guild' import { GuildWithDefaultChannelId } from '../models/Guild'
import { handleSocketData, SocketData } from '../tools/handleSocketData' import { handleSocketData, SocketData } from '../tools/handleSocketData'
import { GUILDS_CACHE_KEY } from '../tools/cache'
export interface Guilds { export interface Guilds {
guilds: GuildWithDefaultChannelId[] guilds: GuildWithDefaultChannelId[]
@ -29,19 +30,20 @@ export const GuildsProvider: React.FC<React.PropsWithChildren<{}>> = (
setItems setItems
} = usePagination<GuildWithDefaultChannelId>({ } = usePagination<GuildWithDefaultChannelId>({
api: authentication.api, api: authentication.api,
url: '/guilds' url: '/guilds',
cacheKey: GUILDS_CACHE_KEY
}) })
useEffect(() => { useEffect(() => {
authentication.socket.on( authentication?.socket?.on(
'guilds', 'guilds',
(data: SocketData<GuildWithDefaultChannelId>) => { (data: SocketData<GuildWithDefaultChannelId>) => {
handleSocketData({ data, setItems }) handleSocketData({ data, setItems, cacheKey: GUILDS_CACHE_KEY })
} }
) )
return () => { return () => {
authentication.socket.off('guilds') authentication?.socket?.off('guilds')
} }
}, [authentication.socket, setItems]) }, [authentication.socket, setItems])

View File

@ -6,6 +6,7 @@ import { MemberWithPublicUser } from '../models/Member'
import { GuildsChannelsPath } from '../components/Application' import { GuildsChannelsPath } from '../components/Application'
import { handleSocketData, SocketData } from '../tools/handleSocketData' import { handleSocketData, SocketData } from '../tools/handleSocketData'
import { User } from '../models/User' import { User } from '../models/User'
import { CacheKey, MEMBERS_CACHE_KEY } from '../tools/cache'
export interface Members { export interface Members {
members: MemberWithPublicUser[] members: MemberWithPublicUser[]
@ -27,6 +28,8 @@ export const MembersProviders: React.FC<
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const cacheKey: CacheKey = `${path.guildId}-${MEMBERS_CACHE_KEY}`
const { const {
items: members, items: members,
hasMore, hasMore,
@ -35,18 +38,19 @@ export const MembersProviders: React.FC<
setItems setItems
} = usePagination<MemberWithPublicUser>({ } = usePagination<MemberWithPublicUser>({
api: authentication.api, api: authentication.api,
url: `/guilds/${path.guildId}/members` url: `/guilds/${path.guildId}/members`,
cacheKey
}) })
useEffect(() => { useEffect(() => {
authentication.socket.on( authentication?.socket?.on(
'members', 'members',
(data: SocketData<MemberWithPublicUser>) => { (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) => { setItems((oldItems) => {
const newItems = [...oldItems] const newItems = [...oldItems]
switch (data.action) { switch (data.action) {
@ -65,10 +69,10 @@ export const MembersProviders: React.FC<
}) })
return () => { return () => {
authentication.socket.off('members') authentication?.socket?.off('members')
authentication.socket.off('users') authentication?.socket?.off('users')
} }
}, [authentication.socket, setItems]) }, [authentication.socket, setItems, cacheKey])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()

View File

@ -5,6 +5,7 @@ import { useAuthentication } from '../tools/authentication'
import { MessageWithMember } from '../models/Message' import { MessageWithMember } from '../models/Message'
import { GuildsChannelsPath } from '../components/Application' import { GuildsChannelsPath } from '../components/Application'
import { handleSocketData, SocketData } from '../tools/handleSocketData' import { handleSocketData, SocketData } from '../tools/handleSocketData'
import { CacheKey, MESSAGES_CACHE_KEY } from '../tools/cache'
export interface Messages { export interface Messages {
messages: MessageWithMember[] messages: MessageWithMember[]
@ -25,6 +26,8 @@ export const MessagesProvider: React.FC<
const { path, children } = props const { path, children } = props
const { authentication, user } = useAuthentication() const { authentication, user } = useAuthentication()
const cacheKey: CacheKey = `${path.channelId}-${MESSAGES_CACHE_KEY}`
const { const {
items: messages, items: messages,
hasMore, hasMore,
@ -34,11 +37,12 @@ export const MessagesProvider: React.FC<
} = usePagination<MessageWithMember>({ } = usePagination<MessageWithMember>({
api: authentication.api, api: authentication.api,
url: `/channels/${path.channelId}/messages`, url: `/channels/${path.channelId}/messages`,
inverse: true inverse: true,
cacheKey
}) })
useEffect(() => { useEffect(() => {
authentication.socket.on( authentication?.socket?.on(
'messages', 'messages',
(data: SocketData<MessageWithMember>) => { (data: SocketData<MessageWithMember>) => {
if (data.item.channelId === path.channelId) { if (data.item.channelId === path.channelId) {
@ -48,7 +52,7 @@ export const MessagesProvider: React.FC<
const isAtBottom = const isAtBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.scrollHeight - messagesDiv.scrollTop <=
messagesDiv.clientHeight messagesDiv.clientHeight
handleSocketData({ data, setItems }) handleSocketData({ data, setItems, cacheKey })
if ( if (
data.action === 'create' && data.action === 'create' &&
(isAtBottom || data.item.member.userId === user.id) (isAtBottom || data.item.member.userId === user.id)
@ -60,9 +64,9 @@ export const MessagesProvider: React.FC<
) )
return () => { return () => {
authentication.socket.off('messages') authentication?.socket?.off('messages')
} }
}, [authentication.socket, setItems, path, user.id]) }, [authentication.socket, setItems, path, user.id, cacheKey])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()

View File

@ -2,6 +2,11 @@ import { useState, useRef, useCallback } from 'react'
import { AxiosInstance } from 'axios' import { AxiosInstance } from 'axios'
import { FetchState } from './useFetchState' import { FetchState } from './useFetchState'
import {
CacheKey,
getPaginationCache,
savePaginationCache
} from '../tools/cache'
export interface Query { export interface Query {
[key: string]: string [key: string]: string
@ -13,6 +18,11 @@ export interface UsePaginationOptions {
api: AxiosInstance api: AxiosInstance
url: string url: string
inverse?: boolean inverse?: boolean
cacheKey?: CacheKey
}
export interface PaginationItem {
id: number
} }
export type SetItems<T> = React.Dispatch<React.SetStateAction<T[]>> export type SetItems<T> = React.Dispatch<React.SetStateAction<T[]>>
@ -25,16 +35,25 @@ export interface UsePaginationResult<T> {
setItems: SetItems<T> setItems: SetItems<T>
} }
export const usePagination = <T extends { id: number }>( export const usePagination = <T extends PaginationItem>(
options: UsePaginationOptions options: UsePaginationOptions
): UsePaginationResult<T> => { ): UsePaginationResult<T> => {
const { api, url, inverse = false } = options const { api, url, inverse = false, cacheKey } = options
const [items, setItems] = useState<T[]>([]) const [items, setItems] = useState<T[]>([])
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const fetchState = useRef<FetchState>('idle') const fetchState = useRef<FetchState>('idle')
const afterId = useRef<number | null>(null) 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( const nextPageAsync: NextPageAsync = useCallback(
async (query) => { async (query) => {
if (fetchState.current !== 'idle') { if (fetchState.current !== 'idle') {
@ -49,19 +68,22 @@ export const usePagination = <T extends { id: number }>(
const { data: newItems } = await api.get<T[]>( const { data: newItems } = await api.get<T[]>(
`${url}?${searchParameters.toString()}` `${url}?${searchParameters.toString()}`
) )
if (!inverse) { updateAfterId(newItems)
afterId.current =
newItems.length > 0 ? newItems[newItems.length - 1].id : null
} else {
afterId.current = newItems.length > 0 ? newItems[0].id : null
}
setItems((oldItems) => { 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) setHasMore(newItems.length > 0)
fetchState.current = 'idle' 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( const nextPage: NextPage = useCallback(
@ -80,9 +102,17 @@ export const usePagination = <T extends { id: number }>(
) )
const resetPagination = useCallback((): void => { const resetPagination = useCallback((): void => {
if (cacheKey == null) {
afterId.current = null afterId.current = null
setItems([]) 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 } return { items, hasMore, nextPage, resetPagination, setItems }
} }

View File

@ -7,7 +7,7 @@ const nextTranslate = require('next-translate')
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
module.exports = nextTranslate( module.exports = nextTranslate(
nextPWA({ nextPWA({
reactStrictMode: true, reactStrictMode: false,
images: { images: {
domains: [ domains: [
'api.thream.divlo.fr', 'api.thream.divlo.fr',

View File

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

View File

@ -9,15 +9,23 @@ import { fetchRefreshToken } from './authenticationFromServerSide'
export class Authentication { export class Authentication {
public tokens: Tokens public tokens: Tokens
public accessTokenAge: number public accessTokenAge: number
public socket: Socket public socket?: Socket
public api: AxiosInstance public api: AxiosInstance
constructor(tokens: Tokens) { constructor(tokens: Tokens, disableSocketIO: boolean = false) {
this.tokens = tokens this.tokens = tokens
this.accessTokenAge = Date.now() this.accessTokenAge = Date.now()
if (disableSocketIO || typeof window === 'undefined') {
this.socket = undefined
} else {
this.socket = io(API_URL, { this.socket = io(API_URL, {
auth: { token: `Bearer ${tokens.accessToken}` } 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) => { this.socket.on('connect_error', (error) => {
if (error.message.startsWith('Unauthorized')) { if (error.message.startsWith('Unauthorized')) {
fetchRefreshToken(this.tokens.refreshToken) fetchRefreshToken(this.tokens.refreshToken)
@ -30,6 +38,7 @@ export class Authentication {
}) })
} }
}) })
}
this.api = axios.create({ this.api = axios.create({
baseURL: API_URL, baseURL: API_URL,
headers: { headers: {
@ -83,13 +92,14 @@ export class Authentication {
this.tokens.accessToken = accessToken this.tokens.accessToken = accessToken
this.accessTokenAge = Date.now() this.accessTokenAge = Date.now()
const token = `${this.tokens.type} ${this.tokens.accessToken}` 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 this.socket.auth.token = token
} }
} }
public signout(): void { public signout(): void {
cookies.remove('refreshToken') cookies.remove('refreshToken')
window.localStorage.clear()
window.location.href = '/authentication/signin' 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 [user, setUser] = useState<UserCurrent>(props.authentication.user)
const authentication = useMemo(() => { 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 // 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(() => { useEffect(() => {
setLanguage(props.authentication.user.settings.language).catch(() => {}) setLanguage(props.authentication.user.settings.language).catch(() => {})
setTheme(props.authentication.user.settings.theme) 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 // 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 { } else {
try { try {
let data: any = {} let data: any = {}
const authentication = new Authentication(tokens) const authentication = new Authentication(tokens, true)
const { data: currentUser } = await authentication.api.get< const { data: currentUser } = await authentication.api.get<
unknown, unknown,
AxiosResponse<UserCurrent> 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 { SetItems } from '../hooks/usePagination'
import { CacheKey, savePaginationCache } from './cache'
export interface Item { export interface Item {
id: number id: number
@ -13,6 +14,7 @@ export interface SocketData<T extends Item = Item> {
export interface HandleSocketDataOptions<T extends Item = Item> { export interface HandleSocketDataOptions<T extends Item = Item> {
setItems: SetItems<T> setItems: SetItems<T>
data: SocketData<T> data: SocketData<T>
cacheKey?: CacheKey
} }
export type SocketListener = (data: SocketData) => void export type SocketListener = (data: SocketData) => void
@ -20,7 +22,8 @@ export type SocketListener = (data: SocketData) => void
export const handleSocketData = <T extends Item = Item>( export const handleSocketData = <T extends Item = Item>(
options: HandleSocketDataOptions<T> options: HandleSocketDataOptions<T>
): void => { ): void => {
const { data, setItems } = options const { data, setItems, cacheKey } = options
console.log('socket.io data received: ', data)
setItems((oldItems) => { setItems((oldItems) => {
const newItems = [...oldItems] const newItems = [...oldItems]
@ -47,6 +50,9 @@ export const handleSocketData = <T extends Item = Item>(
break break
} }
} }
if (cacheKey != null) {
savePaginationCache(cacheKey, newItems)
}
return newItems return newItems
}) })
} }