UNPKG

@techsio/storefront-data

Version:

Medusa-first storefront data layer built on TanStack Query.

499 lines (498 loc) • 22.8 kB
import { useInfiniteQuery, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { createCacheConfig, getPrefetchCacheOptions } from "../shared/cache-config.js"; import { toErrorMessage } from "../shared/error-utils.js"; import { resolvePagination } from "../shared/pagination.js"; import { shouldSkipPrefetch } from "../shared/prefetch.js"; import { createPrefetchPagesPlan } from "../shared/prefetch-pages-plan.js"; import { appendQueryKey } from "../shared/query-keys.js"; import { useRegionContext } from "../shared/region-context.js"; import { useDelayedPrefetchController } from "../shared/use-delayed-prefetch-controller.js"; import { createProductDetailQueryDefinition, createProductListQueryDefinition, resolveProductQueryInput } from "./query-definition.js"; import { createProductQueryKeys } from "./query-keys.js"; import { createProductQueryOptionsFactory } from "./query-options.js"; const isInfiniteProductsPageParam = (pageParam)=>"object" == typeof pageParam && null !== pageParam && "number" == typeof pageParam.offset && "number" == typeof pageParam.page; const resolveInfiniteProductsPageParam = ({ pageParam, baseOffset, basePage, resolvedLimit })=>{ if (isInfiniteProductsPageParam(pageParam)) return pageParam; const offset = "number" == typeof pageParam ? pageParam : baseOffset; const page = resolvedLimit > 0 ? basePage + Math.max(Math.floor((offset - baseOffset) / resolvedLimit), 0) : basePage; return { offset, page }; }; function createProductHooks({ service, buildListParams, buildPrefetchParams, buildDetailParams, queryKeys, queryKeyNamespace = "storefront-data", cacheConfig, defaultPageSize = 20, requireRegion = true }) { const resolvedCacheConfig = cacheConfig ?? createCacheConfig(); const resolvedQueryKeys = queryKeys ?? createProductQueryKeys(queryKeyNamespace); const buildList = buildListParams ?? ((input)=>input); const buildPrefetch = buildPrefetchParams ?? ((input)=>buildList(input)); const buildDetail = buildDetailParams ?? ((input)=>input); const { getListQueryOptions, getDetailQueryOptions } = createProductQueryOptionsFactory({ service, buildListParams: buildList, buildDetailParams: buildDetail, queryKeys: resolvedQueryKeys, cacheConfig: resolvedCacheConfig }); const createProductsListPrefetchQueryOptions = (input, options)=>{ const { queryKey, queryFn } = createProductListQueryDefinition({ input, region: options?.region, service, buildListParams: buildList, queryKeys: resolvedQueryKeys, useGlobalFetcher: options?.useGlobalFetcher }); const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, options?.cacheStrategy ?? "semiStatic"); return { queryKey, queryFn, ...prefetchCacheOptions, meta: options?.prefetchedBy ? { prefetchedBy: options.prefetchedBy } : void 0 }; }; const createProductsFirstPagePrefetchQueryOptions = (input, options)=>{ const { queryKey, queryFn } = createProductListQueryDefinition({ input, region: options?.region, service, buildListParams: buildPrefetch, queryKeys: resolvedQueryKeys, useGlobalFetcher: options?.useGlobalFetcher, transformInput: (resolvedInput)=>({ ...resolvedInput, page: 1, offset: 0 }) }); const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, options?.cacheStrategy ?? "semiStatic"); return { queryKey, queryFn, ...prefetchCacheOptions, meta: options?.prefetchedBy ? { prefetchedBy: options.prefetchedBy } : void 0 }; }; const createProductPrefetchQueryOptions = (input, options)=>{ const { queryKey, queryFn } = createProductDetailQueryDefinition({ input, region: options?.region, service, buildDetailParams: buildDetail, queryKeys: resolvedQueryKeys }); const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, options?.cacheStrategy ?? "semiStatic"); return { queryKey, queryFn, ...prefetchCacheOptions, meta: options?.prefetchedBy ? { prefetchedBy: options.prefetchedBy } : void 0 }; }; function useProducts(input, options) { const contextRegion = useRegionContext(); const { enabled: inputEnabled } = input; const resolvedInput = resolveProductQueryInput(input, contextRegion); const listParams = buildList(resolvedInput); const enabled = inputEnabled ?? (!requireRegion || Boolean(resolvedInput.region_id)); const query = useQuery({ ...getListQueryOptions(input, { queryOptions: options?.queryOptions, region: contextRegion }), enabled }); const { data, isLoading, isFetching, isSuccess, error } = query; const limitFromParams = listParams.limit; const offsetFromParams = listParams.offset; const pagination = resolvePagination({ page: resolvedInput.page, limit: limitFromParams ?? resolvedInput.limit, offset: offsetFromParams }, defaultPageSize); const totalCount = data?.count ?? 0; const totalPages = pagination.limit ? Math.ceil(totalCount / pagination.limit) : 0; return { products: data?.products ?? [], isLoading, isFetching, isSuccess, error: toErrorMessage(error), totalCount, currentPage: pagination.page, totalPages, hasNextPage: pagination.page < totalPages, hasPrevPage: pagination.page > 1, query }; } const resolveInfiniteProductsInput = (input, contextRegion)=>{ const { enabled: inputEnabled, initialLimit, ...baseInput } = input; const resolvedInput = resolveProductQueryInput({ ...baseInput }, contextRegion); const enabled = inputEnabled ?? (!requireRegion || Boolean(resolvedInput.region_id)); const limitFromInput = resolvedInput.limit; const resolvedLimit = "number" == typeof limitFromInput && limitFromInput > 0 ? limitFromInput : defaultPageSize; const resolvedInitialLimit = "number" == typeof initialLimit && initialLimit > 0 ? initialLimit : void 0; const initialPageLimit = resolvedInitialLimit ?? resolvedLimit; const offsetFromInput = resolvedInput.offset; const pageFromInput = resolvedInput.page ?? 1; const baseOffset = "number" == typeof offsetFromInput ? offsetFromInput : (pageFromInput - 1) * resolvedLimit; return { enabled, resolvedInput, resolvedLimit, resolvedInitialLimit, initialPageLimit, baseOffset, basePage: pageFromInput }; }; const buildInfiniteProductsQueryKey = (baseListParams, resolvedInitialLimit)=>{ const infiniteBaseQueryKey = resolvedQueryKeys.infinite ? resolvedQueryKeys.infinite(baseListParams) : appendQueryKey(resolvedQueryKeys.list(baseListParams), "infinite"); if ("number" != typeof resolvedInitialLimit) return infiniteBaseQueryKey; return appendQueryKey(infiniteBaseQueryKey, { initialLimit: resolvedInitialLimit }); }; function useInfiniteProducts(input, options) { const contextRegion = useRegionContext(); const { enabled, resolvedInput, resolvedLimit, resolvedInitialLimit, initialPageLimit, baseOffset, basePage } = resolveInfiniteProductsInput(input, contextRegion); const baseListParams = buildList(resolvedInput); const resolvedQueryKey = buildInfiniteProductsQueryKey(baseListParams, resolvedInitialLimit); const query = useInfiniteQuery({ queryKey: resolvedQueryKey, queryFn: ({ pageParam, signal })=>{ const { offset, page } = resolveInfiniteProductsPageParam({ pageParam, baseOffset, basePage, resolvedLimit }); const limitForPage = page === basePage ? initialPageLimit : resolvedLimit; const pageInput = { ...resolvedInput, page, limit: limitForPage, offset }; const listParams = buildList(pageInput); return service.getProducts(listParams, signal); }, initialPageParam: { offset: baseOffset, page: basePage }, getNextPageParam: (lastPage, _pages, lastPageParam)=>{ const { page } = resolveInfiniteProductsPageParam({ pageParam: lastPageParam, baseOffset, basePage, resolvedLimit }); const limit = lastPage.limit ?? resolvedLimit; const offset = lastPage.offset ?? 0; const moreItemsExist = lastPage.count > offset + limit; return moreItemsExist ? { offset: offset + limit, page: page + 1 } : void 0; }, enabled, ...resolvedCacheConfig.semiStatic, ...options?.queryOptions ?? {} }); const { data, isLoading, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error, isSuccess } = query; return { products: data?.pages.flatMap((page)=>page.products) ?? [], isLoading, isFetching, isFetchingNextPage, hasNextPage: Boolean(hasNextPage), error: toErrorMessage(error), totalCount: data?.pages[0]?.count ?? 0, isSuccess, fetchNextPage: ()=>fetchNextPage(), refetch: ()=>refetch(), query }; } function useSuspenseProducts(input, options) { const contextRegion = useRegionContext(); const resolvedInput = resolveProductQueryInput(input, contextRegion); if (requireRegion && !resolvedInput.region_id) throw new Error("Region is required for product queries"); const listParams = buildList(resolvedInput); const query = useSuspenseQuery(getListQueryOptions(input, { queryOptions: options?.queryOptions, region: contextRegion })); const { data, isFetching } = query; const limitFromParams = listParams.limit; const offsetFromParams = listParams.offset; const pagination = resolvePagination({ page: resolvedInput.page, limit: limitFromParams ?? resolvedInput.limit, offset: offsetFromParams }, defaultPageSize); const totalCount = data?.count ?? 0; const totalPages = pagination.limit ? Math.ceil(totalCount / pagination.limit) : 0; return { products: data?.products ?? [], isLoading: false, isFetching, isSuccess: true, error: null, totalCount, currentPage: pagination.page, totalPages, hasNextPage: pagination.page < totalPages, hasPrevPage: pagination.page > 1, query }; } function useProduct(input, options) { const contextRegion = useRegionContext(); const { enabled: inputEnabled } = input; const resolvedInput = resolveProductQueryInput(input, contextRegion); const enabled = inputEnabled ?? (Boolean(resolvedInput.handle) && (!requireRegion || Boolean(resolvedInput.region_id))); const query = useQuery({ ...getDetailQueryOptions(input, { queryOptions: options?.queryOptions, region: contextRegion }), enabled }); const { data, isLoading, isFetching, isSuccess, error } = query; return { product: data ?? null, isLoading, isFetching, isSuccess, error: toErrorMessage(error), query }; } function useSuspenseProduct(input, options) { const contextRegion = useRegionContext(); const resolvedInput = resolveProductQueryInput(input, contextRegion); if (requireRegion && !resolvedInput.region_id) throw new Error("Region is required for product queries"); if (!resolvedInput.handle) throw new Error("Product handle is required for product queries"); const query = useSuspenseQuery(getDetailQueryOptions(input, { queryOptions: options?.queryOptions, region: contextRegion })); const { data, isFetching } = query; return { product: data ?? null, isLoading: false, isFetching, isSuccess: true, error: null, query }; } function usePrefetchProducts(options) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const { schedulePrefetch, cancelPrefetch } = useDelayedPrefetchController(); const cacheStrategy = options?.cacheStrategy ?? "semiStatic"; const defaultDelay = options?.defaultDelay ?? 800; const skipIfCached = options?.skipIfCached ?? true; const skipMode = options?.skipMode ?? "fresh"; const prefetchProducts = async (input, prefetchOptions)=>{ const resolvedInput = resolveProductQueryInput(input, contextRegion); if (requireRegion && !resolvedInput.region_id) return; const useGlobalFetcher = prefetchOptions?.useGlobalFetcher && service.getProductsGlobal; const skipIfCachedResolved = prefetchOptions?.skipIfCached ?? skipIfCached; const skipModeResolved = prefetchOptions?.skipMode ?? skipMode; const cacheStrategyResolved = prefetchOptions?.cacheStrategy ?? cacheStrategy; const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, cacheStrategyResolved); if (shouldSkipPrefetch({ queryClient, queryKey: createProductsListPrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, region: contextRegion, useGlobalFetcher: Boolean(useGlobalFetcher) }).queryKey, cacheOptions: prefetchCacheOptions, skipIfCached: skipIfCachedResolved, skipMode: skipModeResolved })) return; await queryClient.prefetchQuery(createProductsListPrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, prefetchedBy: prefetchOptions?.prefetchedBy, region: contextRegion, useGlobalFetcher: Boolean(useGlobalFetcher) })); }; const prefetchFirstPage = async (input, prefetchOptions)=>{ const resolvedInput = resolveProductQueryInput(input, contextRegion); if (requireRegion && !resolvedInput.region_id) return; const useGlobalFetcher = prefetchOptions?.useGlobalFetcher && service.getProductsGlobal; const skipIfCachedResolved = prefetchOptions?.skipIfCached ?? skipIfCached; const skipModeResolved = prefetchOptions?.skipMode ?? skipMode; const cacheStrategyResolved = prefetchOptions?.cacheStrategy ?? cacheStrategy; const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, cacheStrategyResolved); if (shouldSkipPrefetch({ queryClient, queryKey: createProductsFirstPagePrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, region: contextRegion, useGlobalFetcher: Boolean(useGlobalFetcher) }).queryKey, cacheOptions: prefetchCacheOptions, skipIfCached: skipIfCachedResolved, skipMode: skipModeResolved })) return; await queryClient.prefetchQuery(createProductsFirstPagePrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, prefetchedBy: prefetchOptions?.prefetchedBy, region: contextRegion, useGlobalFetcher: Boolean(useGlobalFetcher) })); }; const delayedPrefetch = (input, delay = defaultDelay, prefetchId)=>{ const resolvedInput = resolveProductQueryInput(input, contextRegion); const listParams = buildList(resolvedInput); const queryKey = resolvedQueryKeys.list(listParams); const id = prefetchId ?? JSON.stringify(queryKey); return schedulePrefetch(()=>{ prefetchProducts(input); }, id, delay); }; return { prefetchProducts, prefetchFirstPage, delayedPrefetch, cancelPrefetch }; } function usePrefetchProduct(options) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const { schedulePrefetch, cancelPrefetch } = useDelayedPrefetchController(); const cacheStrategy = options?.cacheStrategy ?? "semiStatic"; const defaultDelay = options?.defaultDelay ?? 400; const skipIfCached = options?.skipIfCached ?? true; const skipMode = options?.skipMode ?? "fresh"; const prefetchProduct = async (input, prefetchOptions)=>{ const resolvedInput = resolveProductQueryInput(input, contextRegion); if (requireRegion && !resolvedInput.region_id) return; if (!resolvedInput.handle) return; const skipIfCachedResolved = prefetchOptions?.skipIfCached ?? skipIfCached; const skipModeResolved = prefetchOptions?.skipMode ?? skipMode; const cacheStrategyResolved = prefetchOptions?.cacheStrategy ?? cacheStrategy; const prefetchCacheOptions = getPrefetchCacheOptions(resolvedCacheConfig, cacheStrategyResolved); if (shouldSkipPrefetch({ queryClient, queryKey: createProductPrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, region: contextRegion }).queryKey, cacheOptions: prefetchCacheOptions, skipIfCached: skipIfCachedResolved, skipMode: skipModeResolved })) return; await queryClient.prefetchQuery(createProductPrefetchQueryOptions(input, { cacheStrategy: cacheStrategyResolved, prefetchedBy: prefetchOptions?.prefetchedBy, region: contextRegion })); }; const delayedPrefetch = (input, delay = defaultDelay, prefetchId)=>{ const resolvedInput = resolveProductQueryInput(input, contextRegion); const detailParams = buildDetail(resolvedInput); const queryKey = resolvedQueryKeys.detail(detailParams); const id = prefetchId ?? JSON.stringify(queryKey); return schedulePrefetch(()=>{ prefetchProduct(input); }, id, delay); }; return { prefetchProduct, delayedPrefetch, cancelPrefetch }; } function usePrefetchPages(params) { const queryClient = useQueryClient(); const contextRegion = useRegionContext(); const resolvedBaseInput = useMemo(()=>resolveProductQueryInput(params.baseInput, contextRegion), [ params.baseInput, contextRegion ]); useEffect(()=>{ if (false === params.enabled || false === params.shouldPrefetch) return; if (requireRegion && !resolvedBaseInput.region_id) return; const cacheStrategy = params.cacheStrategy ?? "semiStatic"; const mode = params.mode ?? "priority"; const mediumDelay = params.delays?.medium ?? 500; const lowDelay = params.delays?.low ?? 1500; const timers = []; const prefetchPage = (page)=>{ const inputWithPage = { ...resolvedBaseInput, page, limit: params.pageSize }; return queryClient.prefetchQuery(createProductsListPrefetchQueryOptions(inputWithPage, { cacheStrategy })); }; const scheduleDelayedPrefetch = (pages, delay)=>{ if (0 === pages.length) return; timers.push(setTimeout(()=>{ for (const page of pages)prefetchPage(page); }, delay)); }; const plan = createPrefetchPagesPlan({ mode, currentPage: params.currentPage, totalPages: params.totalPages, hasNextPage: params.hasNextPage, hasPrevPage: params.hasPrevPage }); for (const page of plan.immediate)prefetchPage(page); scheduleDelayedPrefetch(plan.medium, mediumDelay); scheduleDelayedPrefetch(plan.low, lowDelay); return ()=>{ for (const timer of timers)clearTimeout(timer); }; }, [ params.enabled, params.shouldPrefetch, resolvedBaseInput, params.currentPage, params.hasNextPage, params.hasPrevPage, params.totalPages, params.pageSize, params.mode, params.cacheStrategy, params.delays?.medium, params.delays?.low, queryClient, requireRegion ]); } return { getListQueryOptions, getDetailQueryOptions, useProducts, useInfiniteProducts, useSuspenseProducts, useProduct, useSuspenseProduct, usePrefetchProducts, usePrefetchProduct, usePrefetchPages }; } export { createProductHooks };