diff --git a/src/lib/requests/CustomCache.ts b/src/lib/requests/CustomCache.ts new file mode 100644 index 0000000000..1767921847 --- /dev/null +++ b/src/lib/requests/CustomCache.ts @@ -0,0 +1,40 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// eslint-disable-next-line import/prefer-default-export +export class CustomCache { + private keyToResponseMap = new Map(); + + private keyToFetchTimestampMap = new Map(); + + public readonly createKeyFn = (endpoint: string, data: unknown): string => `${endpoint}_${JSON.stringify(data)}`; + + constructor(createKeyFn?: (endpoint: string, data: unknown) => string) { + this.createKeyFn = createKeyFn ?? this.createKeyFn; + } + + public getKeyFor(key: string, data: unknown): string { + return this.createKeyFn(key, data); + } + + public cacheResponse(endpoint: string, data: unknown, response: unknown) { + const createdKey = this.getKeyFor(endpoint, data); + this.keyToFetchTimestampMap.set(createdKey, Date.now()); + this.keyToResponseMap.set(createdKey, response); + } + + public getFetchTimestampFor(endpoint: string, data: unknown): number | undefined { + const key = this.getKeyFor(endpoint, data); + return this.keyToFetchTimestampMap.get(key); + } + + public getResponseFor(endpoint: string, data: unknown): Response | undefined { + const key = this.getKeyFor(endpoint, data); + return this.keyToResponseMap.get(key) as Response; + } +} diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 46fa942140..48a8682d66 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -10,6 +10,8 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios'; import useSWR, { Middleware, SWRConfiguration, SWRResponse } from 'swr'; import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteResponse } from 'swr/infinite'; import { + ApolloError, + DocumentNode, FetchResult, MutationHookOptions, MutationOptions, @@ -21,7 +23,7 @@ import { useQuery, } from '@apollo/client'; import { OperationVariables } from '@apollo/client/core'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { BackupValidationResult, ICategory, @@ -29,7 +31,6 @@ import { IMangaChapter, ISourceFilters, PaginatedList, - PaginatedMangaList, SourcePreferences, SourceSearchResult, } from '@/typings.ts'; @@ -173,6 +174,7 @@ import { UPDATE_LIBRARY_MANGAS, } from '@/lib/graphql/mutations/UpdaterMutation.ts'; import { GET_UPDATE_STATUS } from '@/lib/graphql/queries/UpdaterQuery.ts'; +import { CustomCache } from '@/lib/requests/CustomCache.ts'; enum SWRHttpMethod { SWR_GET, @@ -205,6 +207,12 @@ type SWRInfiniteResponseLoadInfo = { isInitialLoad: boolean; isLoadMore: boolean; }; + +type ApolloPaginatedMutationOptions = MutationHookOptions< + Data, + Variables +> & { skipRequest?: boolean }; + type AbortableRequest = { abortRequest: AbortController['abort'] }; export type AbortableAxiosResponse = { response: Promise } & AbortableRequest; export type AbortableSWRResponse = SWRResponse & AbortableRequest; @@ -212,22 +220,38 @@ export type AbortableSWRInfiniteResponse = SWRInfiniteR AbortableRequest & SWRInfiniteResponseLoadInfo; -type AbortableApolloUseQueryResponse< +export type AbortableApolloUseQueryResponse< Data = any, Variables extends OperationVariables = OperationVariables, > = QueryResult & AbortableRequest; -type AbortableApolloUseMutationResponse = [ - MutationTuple[0], - MutationTuple[1] & AbortableRequest, -]; -type AbortableApolloUseMutationPaginatedResponse< +export type AbortableApolloUseMutationResponse< + Data = any, + Variables extends OperationVariables = OperationVariables, +> = [MutationTuple[0], MutationTuple[1] & AbortableRequest]; +export type AbortableApolloUseMutationPaginatedResponse< Data = any, Variables extends OperationVariables = OperationVariables, > = [ (page: number) => Promise>, - (MutationTuple[1] & AbortableRequest & { size: number; loadingMore: boolean })[], + (Omit[1], 'loading'> & + AbortableRequest & { + size: number; + /** + * Indicates whether any request is currently active. + * In case only "isLoading" is true, it means that it's the initial request + */ + isLoading: boolean; + /** + * Indicates if a next page is being fetched, which is not part of the initial pages + */ + isLoadingMore: boolean; + /** + * Indicates if the cached pages are currently getting revalidated + */ + isValidating: boolean; + })[], ]; -type AbortableApolloMutationResponse = { response: Promise> } & AbortableRequest; +export type AbortableApolloMutationResponse = { response: Promise> } & AbortableRequest; const isLoadingMore = (swrResult: SWRInfiniteResponse): boolean => { const isNextPageMissing = !!swrResult.data && typeof swrResult.data[swrResult.size - 1] === 'undefined'; @@ -266,6 +290,8 @@ export class RequestManager { private readonly restClient: RestClient = new RestClient(); + private readonly cache = new CustomCache(); + public getClient(): IRestClient { return this.restClient; } @@ -299,6 +325,339 @@ export class RequestManager { return `${this.getBaseUrl()}${apiVersion}${endpoint}`; } + private createAbortController(): { signal: AbortSignal } & AbortableRequest { + const abortController = new AbortController(); + const abortRequest = (reason?: any): void => { + if (!abortController.signal.aborted) { + abortController.abort(reason); + } + }; + + return { signal: abortController.signal, abortRequest }; + } + + private createPaginatedResult( + result: Partial | undefined | null, + defaultPage: number, + page?: number, + ): Result { + const isLoading = !result?.error && (result?.isLoading || !result?.called); + const size = page ?? result?.size ?? defaultPage; + return { + client: this.graphQLClient.client, + abortRequest: () => {}, + reset: () => {}, + called: false, + data: undefined, + error: undefined, + size, + isLoading, + isLoadingMore: isLoading && size > 1, + isValidating: !!result?.isValidating, + ...result, + } as Result; + } + + private async revalidatePage( + cacheResultsKey: string, + cachePagesKey: string, + getVariablesFor: (page: number) => Variables, + options: ApolloPaginatedMutationOptions | undefined, + checkIfCachedPageIsInvalid: ( + cachedResult: AbortableApolloUseMutationPaginatedResponse[1][number] | undefined, + revalidatedResult: FetchResult, + ) => boolean, + hasNextPage: (revalidatedResult: FetchResult) => boolean, + pageToRevalidate: number, + maxPage: number, + signal: AbortSignal, + ): Promise { + const { response: revalidationRequest } = this.doRequestNew( + GQLMethod.MUTATION, + GET_SOURCE_MANGAS_FETCH, + getVariablesFor(pageToRevalidate), + { + ...options, + context: { fetchOptions: { signal } }, + }, + ); + + const revalidationResponse = await revalidationRequest; + const cachedPageData = this.cache.getResponseFor< + AbortableApolloUseMutationPaginatedResponse[1][number] + >(cacheResultsKey, getVariablesFor(pageToRevalidate)); + + const isCachedPageInvalid = checkIfCachedPageIsInvalid(cachedPageData, revalidationResponse); + if (isCachedPageInvalid) { + this.cache.cacheResponse(cacheResultsKey, getVariablesFor(pageToRevalidate), revalidationResponse); + } + + if (!hasNextPage(revalidationResponse)) { + const currentCachedPages = this.cache.getResponseFor>(cachePagesKey, getVariablesFor(0))!; + this.cache.cacheResponse( + cachePagesKey, + getVariablesFor(0), + [...currentCachedPages].filter((cachedPage) => cachedPage <= pageToRevalidate), + ); + [...currentCachedPages] + .filter((cachedPage) => cachedPage > pageToRevalidate) + .forEach((cachedPage) => + this.cache.cacheResponse(cacheResultsKey, getVariablesFor(cachedPage), undefined), + ); + return; + } + + if (isCachedPageInvalid && pageToRevalidate < maxPage) { + await this.revalidatePage( + cacheResultsKey, + cachePagesKey, + getVariablesFor, + options, + checkIfCachedPageIsInvalid, + hasNextPage, + pageToRevalidate + 1, + maxPage, + signal, + ); + } + } + + private async revalidatePages( + activeRevalidationRef: + | [ForInput: Variables, Request: Promise, AbortRequest: AbortableRequest['abortRequest']] + | null, + setRevalidationDone: (isDone: boolean) => void, + setActiveRevalidation: ( + activeRevalidation: + | [ForInput: Variables, Request: Promise, AbortRequest: AbortableRequest['abortRequest']] + | null, + ) => void, + getVariablesFor: (page: number) => Variables, + setValidating: (isValidating: boolean) => void, + revalidatePage: (pageToRevalidate: number, maxPage: number, signal: AbortSignal) => Promise, + maxPage: number, + abortRequest: AbortableRequest['abortRequest'], + signal: AbortSignal, + ): Promise { + setRevalidationDone(true); + + const [currRevVars, currRevPromise, currRevAbortRequest] = activeRevalidationRef ?? []; + + const isActiveRevalidationForInput = JSON.stringify(currRevVars) === JSON.stringify(getVariablesFor(0)); + + setValidating(true); + + if (!isActiveRevalidationForInput) { + currRevAbortRequest?.(new Error('Abort revalidation for different input')); + } + + let revalidationPromise = currRevPromise; + if (!isActiveRevalidationForInput) { + revalidationPromise = revalidatePage(1, maxPage, signal); + setActiveRevalidation([getVariablesFor(0), revalidationPromise, abortRequest]); + } + + try { + await revalidationPromise; + setActiveRevalidation(null); + } catch (e) { + // ignore + } finally { + setValidating(false); + } + } + + private async fetchPaginatedMutationPage< + Data = any, + Variables extends OperationVariables = OperationVariables, + ResultIdInfo extends Record = any, + >( + getVariablesFor: (page: number) => Variables, + setAbortRequest: (abortRequest: AbortableRequest['abortRequest']) => void, + getResultIdInfo: () => ResultIdInfo, + createPaginatedResult: ( + result: Partial[1][number]>, + ) => AbortableApolloUseMutationPaginatedResponse[1][number], + setResult: ( + result: AbortableApolloUseMutationPaginatedResponse[1][number] & ResultIdInfo, + ) => void, + revalidate: ( + maxPage: number, + abortRequest: AbortableRequest['abortRequest'], + signal: AbortSignal, + ) => Promise, + options: ApolloPaginatedMutationOptions | undefined, + documentNode: DocumentNode, + cachePagesKey: string, + cacheResultsKey: string, + cachedPages: Set, + newPage: number, + ): Promise> { + const basePaginatedResult: Partial[1][number]> = { + size: newPage, + isLoading: false, + isLoadingMore: false, + called: true, + }; + + let response: FetchResult = {}; + try { + const { signal, abortRequest } = this.createAbortController(); + setAbortRequest(abortRequest); + + setResult({ + ...getResultIdInfo(), + ...createPaginatedResult({ isLoading: true, abortRequest, size: newPage, called: true }), + }); + + if (newPage !== 1 && cachedPages.size) { + await revalidate(newPage, abortRequest, signal); + } + + const { response: request } = this.doRequestNew( + GQLMethod.MUTATION, + documentNode, + getVariablesFor(newPage), + { ...options, context: { fetchOptions: { signal } } }, + ); + + response = await request; + + basePaginatedResult.data = response.data; + } catch (error: any) { + if (error instanceof ApolloError) { + basePaginatedResult.error = error; + } else { + basePaginatedResult.error = new ApolloError({ + errorMessage: error?.message ?? error.toString(), + extraInfo: error, + }); + } + } + + const fetchPaginatedResult = { + ...getResultIdInfo(), + ...createPaginatedResult(basePaginatedResult), + }; + + setResult(fetchPaginatedResult); + + const shouldCacheResult = !fetchPaginatedResult.error; + if (shouldCacheResult) { + const currentCachedPages = this.cache.getResponseFor>(cachePagesKey, getVariablesFor(0)) ?? []; + this.cache.cacheResponse(cachePagesKey, getVariablesFor(0), new Set([...currentCachedPages, newPage])); + this.cache.cacheResponse(cacheResultsKey, getVariablesFor(newPage), fetchPaginatedResult); + } + + return response; + } + + private fetchInitialPages( + options: ApolloPaginatedMutationOptions | undefined, + areFetchingInitialPages: boolean, + areInitialPagesFetched: boolean, + setRevalidationDone: (isDone: boolean) => void, + cacheInitialPagesKey: string, + getVariablesFor: (page: number) => Variables, + initialPages: number, + fetchPage: (page: number) => Promise>, + hasNextPage: (result: FetchResult) => boolean, + ): void { + const shouldFetchInitialPages = !options?.skipRequest && !areFetchingInitialPages && !areInitialPagesFetched; + if (shouldFetchInitialPages) { + setRevalidationDone(true); + this.cache.cacheResponse(cacheInitialPagesKey, getVariablesFor(0), true); + + const loadInitialPages = async (initialPage: number) => { + const areAllPagesFetched = initialPage > initialPages; + if (areAllPagesFetched) { + return; + } + + const pageResult = await fetchPage(initialPage); + + if (hasNextPage(pageResult)) { + await loadInitialPages(initialPage + 1); + } + }; + + loadInitialPages(1); + } + } + + private returnPaginatedMutationResult( + areInitialPagesFetched: boolean, + cachedResults: AbortableApolloUseMutationPaginatedResponse[1][number][], + getVariablesFor: (page: number) => Variables, + paginatedResult: AbortableApolloUseMutationPaginatedResponse[1][number], + fetchPage: (page: number) => Promise>, + hasCachedResult: boolean, + createPaginatedResult: ( + result: Partial[1][number]>, + ) => AbortableApolloUseMutationPaginatedResponse[1][number], + ): AbortableApolloUseMutationPaginatedResponse { + const doCachedResultsExist = areInitialPagesFetched && cachedResults.length; + if (!doCachedResultsExist) { + return [fetchPage, [paginatedResult]]; + } + + const areAllPagesCached = doCachedResultsExist && hasCachedResult; + if (!areAllPagesCached) { + return [fetchPage, [...cachedResults, paginatedResult]]; + } + + return [ + fetchPage, + [ + ...cachedResults.slice(0, cachedResults.length - 1), + createPaginatedResult({ + ...cachedResults[cachedResults.length - 1], + isValidating: paginatedResult.isValidating, + }), + ], + ]; + } + + private revalidateInitialPages( + isRevalidationDone: boolean, + cachedResultsLength: number, + cachedPages: Set, + setRevalidationDone: (isDone: boolean) => void, + getVariablesFor: (page: number) => Variables, + triggerRerender: () => void, + revalidate: ( + maxPage: number, + abortRequest: AbortableRequest['abortRequest'], + signal: AbortSignal, + ) => Promise, + ): void { + const isMountedRef = useRef(false); + + useEffect(() => { + const isRevalidationRequired = isMountedRef.current && cachedResultsLength; + if (!isRevalidationRequired) { + return; + } + + setRevalidationDone(false); + triggerRerender(); + }, [JSON.stringify(getVariablesFor(0))]); + + useEffect(() => { + const shouldRevalidateData = isMountedRef.current && !isRevalidationDone && cachedResultsLength; + if (shouldRevalidateData) { + setRevalidationDone(true); + + const { signal, abortRequest } = this.createAbortController(); + revalidate(Math.max(...cachedPages), abortRequest, signal); + } + }, [isMountedRef.current, isRevalidationDone]); + + useEffect(() => { + isMountedRef.current = true; + }, []); + } + public getValidImgUrlFor(imageUrl: string, apiVersion: string = ''): string { const useCache = storage.getItem('useCache', true); const useCacheQuery = `?useCache=${useCache}`; @@ -339,13 +698,7 @@ export class RequestManager { | AbortableApolloUseQueryResponse | AbortableApolloUseMutationResponse | AbortableApolloMutationResponse { - const abortController = new AbortController(); - const abortRequest = (reason?: any): void => { - if (!abortController.signal.aborted) { - abortController.abort(reason); - } - }; - + const { signal, abortRequest } = this.createAbortController(); switch (method) { case GQLMethod.USE_QUERY: return { @@ -356,7 +709,7 @@ export class RequestManager { context: { ...options?.context, fetchOptions: { - signal: abortController.signal, + signal, ...options?.context?.fetchOptions, }, }, @@ -372,7 +725,7 @@ export class RequestManager { context: { ...options?.context, fetchOptions: { - signal: abortController.signal, + signal, ...options?.context?.fetchOptions, }, }, @@ -388,7 +741,7 @@ export class RequestManager { context: { ...options?.context, fetchOptions: { - signal: abortController.signal, + signal, ...options?.context?.fetchOptions, }, }, @@ -640,125 +993,209 @@ export class RequestManager { public useGetSourceMangas( input: FetchSourceMangaInput, - options?: MutationHookOptions, + initialPages: number = 1, + options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { + type MutationResult = AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + >[1]; + type MutationDataResult = MutationResult[number]; + const createPaginatedResult = ( - result: AbortableApolloUseMutationResponse[1], - page: number, - ): AbortableApolloUseMutationPaginatedResponse[1][number] => { - const loading = result.loading || !result.called; - return { - ...result, - loading, - size: page, - loadingMore: loading && page > 1, - }; + result?: Partial | null, + page?: number, + ) => this.createPaginatedResult(result, input.page, page); + + const getVariablesFor = (page: number): GetSourceMangasFetchMutationVariables => ({ + input: { + ...input, + page, + }, + }); + + const CACHE_INITIAL_PAGES_FETCHING_KEY = 'GET_SOURCE_MANGAS_FETCH_FETCHING_INITIAL_PAGES'; + const CACHE_PAGES_KEY = 'GET_SOURCE_MANGAS_FETCH_PAGES'; + const CACHE_RESULTS_KEY = 'GET_SOURCE_MANGAS_FETCH'; + + const isRevalidationDoneRef = useRef(false); + const activeRevalidationRef = useRef< + | [ + ForInput: GetSourceMangasFetchMutationVariables, + Request: Promise, + AbortRequest: AbortableRequest['abortRequest'], + ] + | null + >(null); + const abortRequestRef = useRef(() => {}); + const resultRef = useRef<(MutationDataResult & { forInput: string }) | null>(null); + const result = resultRef.current; + + const [, setTriggerRerender] = useState(0); + const triggerRerender = () => setTriggerRerender((prev) => prev + 1); + const setResult = (nextResult: typeof resultRef.current) => { + resultRef.current = nextResult; + triggerRerender(); }; - // TODO - implement caching - // - ? global cache with revalidating (same as SWR does, revalidate each page starting with 1st until the first page is reached whose data didn't change) - // - ? saving fetched mangas in location state and only "cache" when navigating prev/next - const [mutate, result] = this.doRequestNew( - GQLMethod.USE_MUTATION, - GET_SOURCE_MANGAS_FETCH, - { input }, - options, + const cachedPages = this.cache.getResponseFor>(CACHE_PAGES_KEY, getVariablesFor(0)) ?? new Set(); + const cachedResults = [...cachedPages] + .map( + (cachedPage) => + this.cache.getResponseFor(CACHE_RESULTS_KEY, getVariablesFor(cachedPage))!, + ) + .sort((a, b) => a.size - b.size); + const areFetchingInitialPages = !!this.cache.getResponseFor( + CACHE_INITIAL_PAGES_FETCHING_KEY, + getVariablesFor(0), ); - const [previousResults, setPreviousResults] = useState([ - createPaginatedResult(result, input.page), - ]); - - const [contentType, setContentType] = useState(input.type); - const [query, setQuery] = useState(input.query); - const [page, setPage] = useState(input.page); - - const paginatedResult = createPaginatedResult(result, page); + const areInitialPagesFetched = cachedResults.length >= initialPages; + const isResultForCurrentInput = result?.forInput === JSON.stringify(getVariablesFor(0)); + const lastPage = cachedPages.size ? Math.max(...cachedPages) : input.page; + const nextPage = isResultForCurrentInput ? result.size : lastPage; + + const paginatedResult = + isResultForCurrentInput && areInitialPagesFetched ? result : createPaginatedResult(undefined, nextPage); + paginatedResult.abortRequest = abortRequestRef.current; + + // make sure that the result is always for the current input + resultRef.current = { forInput: JSON.stringify(getVariablesFor(0)), ...paginatedResult }; + + const hasCachedResult = !!this.cache.getResponseFor(CACHE_RESULTS_KEY, getVariablesFor(nextPage)); + + const revalidatePage = async (pageToRevalidate: number, maxPage: number, signal: AbortSignal) => + this.revalidatePage( + CACHE_RESULTS_KEY, + CACHE_PAGES_KEY, + getVariablesFor, + options, + (cachedResult, revalidatedResult) => + !cachedResult || + !cachedResult.data?.fetchSourceManga.mangas.length || + cachedResult.data.fetchSourceManga.mangas.some( + (manga, index) => manga.id !== revalidatedResult.data?.fetchSourceManga.mangas[index]?.id, + ), + (revalidatedResult) => !!revalidatedResult.data?.fetchSourceManga.hasNextPage, + pageToRevalidate, + maxPage, + signal, + ); - // TODO - option "global cache with revalidating" - // replace previousResults with cache - // cache specific response - // cache "base" key to specific page keys to be able to retrieve all necessary cached pages - // get cached results - // revalidate in background - revalidate first page -> result changed? revalidate every page until cached result and response is the same + const revalidate = async ( + maxPage: number, + abortRequest: AbortableRequest['abortRequest'], + signal: AbortSignal, + ) => + this.revalidatePages( + activeRevalidationRef.current, + (isDone) => { + isRevalidationDoneRef.current = isDone; + }, + (activeRevalidation) => { + activeRevalidationRef.current = activeRevalidation; + }, + getVariablesFor, + (isValidating) => { + setResult({ + ...createPaginatedResult(resultRef.current), + isValidating, + forInput: JSON.stringify(getVariablesFor(0)), + }); + }, + revalidatePage, + maxPage, + abortRequest, + signal, + ); // wrap "mutate" function to align with the expected type, which allows only passing a "page" argument - const wrappedMutate = (newPage: number) => { - const resetPreviousResultForInitialLoad = newPage < page; - if (resetPreviousResultForInitialLoad) { - setPreviousResults(previousResults.filter((prevResult) => prevResult.size <= newPage)); - } - - if (newPage !== page) { - setPage(newPage); - } - - return mutate({ - variables: { - input: { - ...input, - page: newPage, - }, + const wrappedMutate = async (newPage: number) => + this.fetchPaginatedMutationPage( + getVariablesFor, + (abortRequest) => { + abortRequestRef.current = abortRequest; }, - }); - }; - - const contentTypeChanged = contentType !== input.type; - const queryChanged = query !== input.query; - // instantly return empty results in case the provided variables changed - wait until the hook returns empty data, - // otherwise, updating the previous results will revert the reset - const resetPreviousResult = (queryChanged || contentTypeChanged) && !paginatedResult.data; - let updatedResults = [ - ...(resetPreviousResult ? [{ ...paginatedResult, size: page, loadingMore: false }] : previousResults), - ]; + () => ({ forType: input.type, forQuery: input.query }), + createPaginatedResult, + setResult, + revalidate, + options, + GET_SOURCE_MANGAS_FETCH, + CACHE_PAGES_KEY, + CACHE_RESULTS_KEY, + cachedPages, + newPage, + ); - if (resetPreviousResult) { - setContentType(input.type); - setQuery(input.query); - setPreviousResults([paginatedResult]); - } + this.fetchInitialPages( + options, + areFetchingInitialPages, + areInitialPagesFetched, + (isDone) => { + isRevalidationDoneRef.current = isDone; + }, + CACHE_INITIAL_PAGES_FETCHING_KEY, + getVariablesFor, + initialPages, + wrappedMutate, + (fetchedResult) => !!fetchedResult.data?.fetchSourceManga.hasNextPage, + ); - const resultChanged = previousResults[page - 1]?.loading !== paginatedResult.loading; - const updatePreviousResult = resultChanged && !resetPreviousResult; - if (updatePreviousResult) { - updatedResults = [...previousResults.slice(0, page - 1), paginatedResult]; - setPreviousResults(updatedResults); - } + this.revalidateInitialPages( + isRevalidationDoneRef.current, + cachedResults.length, + cachedPages, + (isDone) => { + isRevalidationDoneRef.current = isDone; + }, + getVariablesFor, + triggerRerender, + revalidate, + ); - return [wrappedMutate, updatedResult]; + return this.returnPaginatedMutationResult( + areInitialPagesFetched, + cachedResults, + getVariablesFor, + paginatedResult, + wrappedMutate, + hasCachedResult, + createPaginatedResult, + ); } public useGetSourcePopularMangas( sourceId: string, initialPages?: number, - swrOptions?: SWRInfiniteOptions, - ): AbortableSWRInfiniteResponse { - return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', { - swrOptions: { - getEndpoint: (page, previousData) => - previousData?.hasNextPage ?? true ? `source/${sourceId}/popular/${page + 1}` : null, - initialSize: initialPages, - ...swrOptions, - } as typeof swrOptions, - }); + options?: ApolloPaginatedMutationOptions, + ): AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + > { + return this.useGetSourceMangas( + { type: FetchSourceMangaType.Popular, source: sourceId, page: 1 }, + initialPages, + options, + ); } public useGetSourceLatestMangas( sourceId: string, initialPages?: number, - swrOptions?: SWRInfiniteOptions, - ): AbortableSWRInfiniteResponse { - return this.doRequest(SWRHttpMethod.SWR_GET_INFINITE, '', { - swrOptions: { - getEndpoint: (page, previousData) => - previousData?.hasNextPage ?? true ? `source/${sourceId}/latest/${page + 1}` : null, - initialSize: initialPages, - ...swrOptions, - } as typeof swrOptions, - }); + options?: ApolloPaginatedMutationOptions, + ): AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + > { + return this.useGetSourceMangas( + { type: FetchSourceMangaType.Latest, source: sourceId, page: 1 }, + initialPages, + options, + ); } public useGetSourcePreferences( @@ -790,14 +1227,19 @@ export class RequestManager { public useSourceSearch( source: string, - query: string, + query?: string, filters?: FilterChangeInput[], - options?: MutationHookOptions, + initialPages?: number, + options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { - return this.useGetSourceMangas({ type: FetchSourceMangaType.Search, source, query, filters, page: 1 }, options); + return this.useGetSourceMangas( + { type: FetchSourceMangaType.Search, source, query, filters, page: 1 }, + initialPages, + options, + ); } public useSourceQuickSearch( diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 1cc6bbd05b..2afc53366a 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -97,20 +97,15 @@ const SourceSearchPreview = React.memo( emptyQuery: boolean; }) => { const { t } = useTranslation(); - const skipRequest = !searchString; const { id, displayName, lang } = source; - const [loadPage, results] = requestManager.useSourceSearch(id, searchString ?? '', []); - const { data: searchResult, loading: isLoading, error, abortRequest } = results[0]!; + const [, results] = requestManager.useSourceSearch(id, searchString ?? '', undefined, 1, { + skipRequest: !searchString, + }); + const { data: searchResult, isLoading, error, abortRequest } = results[0]!; const mangas = (searchResult?.fetchSourceManga.mangas as MangaType[]) ?? []; const noMangasFound = !isLoading && !mangas.length; - useEffect(() => { - if (!skipRequest) { - loadPage(1); - } - }, [skipRequest, searchString]); - useEffect(() => { onSearchRequestFinished(source, isLoading, !noMangasFound, !searchString); }, [isLoading, noMangasFound, searchString]); diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx index 038360b824..46d780ff30 100644 --- a/src/screens/SourceMangas.tsx +++ b/src/screens/SourceMangas.tsx @@ -17,8 +17,8 @@ import { Box, Button, styled, useTheme, useMediaQuery } from '@mui/material'; import FavoriteIcon from '@mui/icons-material/Favorite'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import FilterListIcon from '@mui/icons-material/FilterList'; -import { IManga, PaginatedMangaList, TranslationKey } from '@/typings'; -import requestManager, { AbortableSWRInfiniteResponse } from '@/lib/requests/RequestManager.ts'; +import { TranslationKey } from '@/typings'; +import requestManager, { AbortableApolloUseMutationPaginatedResponse } from '@/lib/requests/RequestManager.ts'; import { useDebounce } from '@/components/manga/hooks'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import SourceGridLayout from '@/components/source/GridLayouts'; @@ -26,6 +26,11 @@ import AppbarSearch from '@/components/util/AppbarSearch'; import SourceOptions from '@/components/source/SourceOptions'; import NavbarContext from '@/components/context/NavbarContext'; import SourceMangaGrid from '@/components/source/SourceMangaGrid'; +import { + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables, + MangaType, +} from '@/lib/graphql/generated/graphql.ts'; const ContentTypeMenu = styled('div')(({ theme }) => ({ display: 'flex', @@ -81,15 +86,8 @@ const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY: { [contentType in SourceContentType] [SourceContentType.SEARCH]: 'manga.error.label.no_mangas_found', }; -type SourceMangaResponse = Omit, 'data'> & { - data: { - items: IManga[]; - hasNextPage: boolean; - }; -}; - -const getUniqueMangas = (mangas: IManga[]): IManga[] => { - const uniqueMangas: IManga[] = []; +const getUniqueMangas = (mangas: MangaType[]): MangaType[] => { + const uniqueMangas: MangaType[] = []; mangas.forEach((manga) => { const isDuplicate = uniqueMangas.some((uniqueManga) => uniqueManga.id === manga.id); @@ -106,9 +104,18 @@ const useSourceManga = ( contentType: SourceContentType, searchTerm: string | null | undefined, filters: IPos[], - initialPages = 1, -): SourceMangaResponse => { - let result: AbortableSWRInfiniteResponse; + initialPages: number, +): [ + AbortableApolloUseMutationPaginatedResponse[0], + AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + >[1][number], +] => { + let result: AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + >; switch (contentType) { case SourceContentType.POPULAR: result = requestManager.useGetSourcePopularMangas(sourceId, initialPages); @@ -117,45 +124,71 @@ const useSourceManga = ( result = requestManager.useGetSourceLatestMangas(sourceId, initialPages); break; case SourceContentType.SEARCH: - result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages); + result = requestManager.useSourceSearch(sourceId, searchTerm ?? '', undefined, initialPages); break; case SourceContentType.FILTER: - result = requestManager.useSourceQuickSearch( - sourceId, - '', - filters.map((filter) => { - const { position, state, group } = filter; - - const isPartOfGroup = group !== undefined; - if (isPartOfGroup) { - return { - position: group, - state: JSON.stringify({ - position, - state, - }), - }; - } - - return filter; - }), - initialPages, - { disableCache: true }, - ); + result = requestManager.useSourceSearch(sourceId, undefined, [], initialPages); + // TODO - update filters to gql + // result = requestManager.useSourceQuickSearch( + // sourceId, + // '', + // filters.map((filter) => { + // const { position, state, group } = filter; + // + // const isPartOfGroup = group !== undefined; + // if (isPartOfGroup) { + // return { + // position: group, + // state: JSON.stringify({ + // position, + // state, + // }), + // }; + // } + // + // return filter; + // }), + // initialPages, + // { disableCache: true }, + // ); break; default: throw new Error(`Unknown ContentType "${contentType}"`); } - const pages = result.data; - const { hasNextPage } = pages?.[pages.length - 1] ?? { hasNextPage: false }; + const pages = result[1]!; + const lastLoadedPageIndex = pages.findLastIndex((page) => !!page.data?.fetchSourceManga); + const lastLoadedPage = pages[lastLoadedPageIndex]; const items = useMemo( - () => (pages ?? []).map((page) => page.mangaList).reduce((prevList, list) => [...prevList, ...list], []), + () => + (pages ?? []) + .map((page) => page.data?.fetchSourceManga.mangas ?? []) + .reduce((prevList, list) => [...prevList, ...list], []), [pages], - ); + ) as MangaType[]; const uniqueItems = useMemo(() => getUniqueMangas(items), [items]); - return { ...result, data: { items: uniqueItems, hasNextPage } }; + if (!uniqueItems.length) { + return [result[0] as any, result[1][result[1].length - 1]]; + } + + return [ + result[0], + { + ...pages[pages.length - 1], + data: { + ...lastLoadedPage!.data, + fetchSourceManga: { + ...lastLoadedPage!.data!.fetchSourceManga, + hasNextPage: + pages.length > lastLoadedPageIndex + 1 + ? false + : lastLoadedPage!.data!.fetchSourceManga.hasNextPage, + mangas: uniqueItems, + }, + }, + }, + ]; }; export default function SourceMangas() { @@ -179,17 +212,19 @@ export default function SourceMangas() { const searchTerm = useDebounce(query, 1000); const [resetScrollPosition, setResetScrollPosition] = useState(false); const [contentType, setContentType] = useState(currentLocationContentType); - const { - data: { items: mangas, hasNextPage } = { items: [], hasNextPage: false }, - isLoading, - size: lastPageNum, - setSize: setPages, - mutate: refreshData, - abortRequest, - } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1); + const [loadPage, { data, isLoading, size: lastPageNum, abortRequest }] = useSourceManga( + sourceId, + contentType, + searchTerm, + filtersToApply, + isLargeScreen ? 2 : 1, + ); + const mangas = (data?.fetchSourceManga.mangas as MangaType[]) ?? []; + const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false; + const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); - const { data } = requestManager.useGetSource(sourceId); - const source = data?.source; + const { data: sourceData } = requestManager.useGetSource(sourceId); + const source = sourceData?.source; const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined; @@ -231,8 +266,8 @@ export default function SourceMangas() { return; } - setPages(lastPageNum + 1); - }, [setPages, lastPageNum, hasNextPage]); + loadPage(lastPageNum + 1); + }, [lastPageNum, hasNextPage, contentType]); const resetFilters = useCallback(async () => { setDialogFiltersToApply([]); @@ -262,12 +297,13 @@ export default function SourceMangas() { [searchTerm, contentType], ); + // TODO - check when fixing filters useEffect(() => { if (!triggerDataRefresh) { return; } - refreshData(); + // refreshData(); setTriggerDataRefresh(false); }, [triggerDataRefresh]); @@ -327,6 +363,7 @@ export default function SourceMangas() {