@techsio/storefront-data
Version:
Medusa-first storefront data layer built on TanStack Query.
499 lines (498 loc) • 22.8 kB
JavaScript
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 };