diff --git a/package.json b/package.json index b4c28ad..691d4f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "http-react", - "version": "3.9.0", + "version": "4.0.0", "description": "React hooks for data fetching", "main": "dist/index.js", "scripts": { diff --git a/src/components/FetchConfigAsync.tsx b/src/components/FetchConfigAsync.tsx index 342a846..19603a9 100644 --- a/src/components/FetchConfigAsync.tsx +++ b/src/components/FetchConfigAsync.tsx @@ -6,11 +6,11 @@ import { FetchConfigSync } from './server' export async function FetchConfigAsync(props: FetchContextType) { const { children, defaults = {}, value = {} } = props - let $values = new Map() + const $values = new Map() - const previousConfig = $context.value as any + const previousConfig = $context.value as typeof $context.value - for (let valueKey in value) { + for (const valueKey in value) { const $value = await value[valueKey] const $data = $value.data ?? $value @@ -22,7 +22,7 @@ export async function FetchConfigAsync(props: FetchContextType) { // cacheProvider.set(resolvedKey, $data) } - for (let defaultKey in defaults) { + for (const defaultKey in defaults) { const { id = defaultKey } = defaults[defaultKey] if (isDefined(id)) { @@ -32,7 +32,7 @@ export async function FetchConfigAsync(props: FetchContextType) { } } - let mergedConfig = { + const mergedConfig = { ...previousConfig, ...props, headers: { diff --git a/src/components/LocalStorageCacheProvider.tsx b/src/components/LocalStorageCacheProvider.tsx index 2a2c578..49b69a3 100644 --- a/src/components/LocalStorageCacheProvider.tsx +++ b/src/components/LocalStorageCacheProvider.tsx @@ -7,13 +7,13 @@ let isCacheHydrated = false const loadFromLocalStorage = () => { if (typeof localStorage !== 'undefined') { - for (let key in localStorage) { + for (const key in localStorage) { try { const currentValue = localStorage.getItem(key) if (typeof currentValue !== 'undefined') { defaultCache.set(key, JSON.parse(currentValue!)) } - } catch (error) { + } catch { // Remove cache key if parsing fails localStorage.removeItem(key) } @@ -51,7 +51,7 @@ export function LocalStorageCacheProvider({ useCacheHydration({ instant }) return ( - // @ts-expect-error + //@ts-expect-error >) + ? { + url: init.url, + method: init.method, + init, + ...options, + id: options?.id ?? options?.key + } + : ({ + ...init, + ...options, + // @ts-expect-error + id: init?.id ?? init?.key + } as Required< + StaticFetchConfig + >) const { onOnline = ctx.onOnline, @@ -166,6 +168,21 @@ export function useFetch< attemptInterval = ctx.attemptInterval, revalidateOnFocus = ctx.revalidateOnFocus, suspense: $suspense, + streaming = ctx.streaming ?? + !isDefined( + optionsConfig?.default ?? + fetcherDefaults.get( + serialize({ + idString: serialize( + optionsConfig.key ?? + [ + optionsConfig.method ?? (METHODS.GET as HTTP_METHODS), + optionsConfig.url ?? ' ' + ].join(' ') + ) + }) + ) + ), onFetchStart = ctx.onFetchStart, onFetchEnd = ctx.onFetchEnd, cacheIfError = ctx.cacheIfError, @@ -177,9 +194,9 @@ export function useFetch< const $fetch = isFunction(fetcher) ? fetcher - : windowExists - ? fetch - : () => new Response(serialize(initialDataValue)) + : windowExists || streaming + ? fetch + : () => new Response(serialize(initialDataValue)) const config = { query, @@ -211,8 +228,8 @@ export function useFetch< optionsConfig.auto === false ? false : isDefined(optionsConfig.retryOnReconnect) - ? optionsConfig.retryOnReconnect - : ctx.retryOnReconnect + ? optionsConfig.retryOnReconnect + : ctx.retryOnReconnect const reqQuery = { ...ctx.query, @@ -228,10 +245,10 @@ export function useFetch< (hasBaseUrl(url) ? '' : !isDefined(config.baseUrl!) - ? !isDefined(ctx.baseUrl!) - ? '' - : ctx.baseUrl! - : config.baseUrl!) + url + ? !isDefined(ctx.baseUrl!) + ? '' + : ctx.baseUrl! + : config.baseUrl!) + url const defaultId = optionsConfig.key ?? [method, url].join(' ') @@ -286,7 +303,9 @@ export function useFetch< if (!suspense) { if (url !== '') { - suspenseInitialized.set(resolvedKey, true) + if (windowExists) { + suspenseInitialized.set(resolvedKey, true) + } } } @@ -385,7 +404,11 @@ export function useFetch< def ?? null - const isLoading = isExpired ? isPending(resolvedKey) || loading : false + const isLoading = suspense + ? false + : isExpired + ? isPending(resolvedKey) || loading + : false const loadingFirst = !(hasData.get(resolvedDataKey) || hasData.get(resolvedKey)) && isLoading @@ -408,10 +431,10 @@ export function useFetch< (hasBaseUrl(url) ? '' : !isDefined(config.baseUrl!) - ? !isDefined(ctx.baseUrl!) - ? '' - : ctx.baseUrl! - : config.baseUrl!) + url + ? !isDefined(ctx.baseUrl!) + ? '' + : ctx.baseUrl! + : config.baseUrl!) + url const urlWithParams = setURLParams(rawUrl, c.params) @@ -519,8 +542,8 @@ export function useFetch< (realUrl.includes('?') ? c.query : c.query - ? '?' + c.query - : c.query) + ? '?' + c.query + : c.query) ).replace('?&', '?'), newRequestConfig ) @@ -1187,48 +1210,51 @@ export function useFetch< useEffect(() => { // Attempts will be made after a request fails - const tm = setTimeout(() => { - if (!gettingAttempts.get(resolvedKey)) { - gettingAttempts.set(resolvedKey, true) - const attempts = - typeof $attempts === 'function' - ? $attempts({ - status: - statusCodes.get(resolvedKey) || - statusCodes.get(resolvedDataKey), - res: lastResponses.get(resolvedKey), - error: - hasErrors.get(resolvedKey) || - hasErrors.get(resolvedDataKey) || - (error as any), - completedAttempts - }) - : $attempts + const tm = setTimeout( + () => { + if (!gettingAttempts.get(resolvedKey)) { + gettingAttempts.set(resolvedKey, true) + const attempts = + typeof $attempts === 'function' + ? $attempts({ + status: + statusCodes.get(resolvedKey) || + statusCodes.get(resolvedDataKey), + res: lastResponses.get(resolvedKey), + error: + hasErrors.get(resolvedKey) || + hasErrors.get(resolvedDataKey) || + (error as any), + completedAttempts + }) + : $attempts - if ((attempts as number) > 0) { - if (completedAttempts < (attempts as number)) { - reValidate() - setCompletedAttempts((previousAttempts: number) => { - let newAttemptsValue = previousAttempts + 1 + if ((attempts as number) > 0) { + if (completedAttempts < (attempts as number)) { + reValidate() + setCompletedAttempts((previousAttempts: number) => { + let newAttemptsValue = previousAttempts + 1 + requestsProvider.emit(resolvedKey, { + requestCallId, + completedAttempts: newAttemptsValue + }) + + return newAttemptsValue + }) + } else if (completedAttempts === attempts) { requestsProvider.emit(resolvedKey, { requestCallId, - completedAttempts: newAttemptsValue + online: false, + error: true }) - - return newAttemptsValue - }) - } else if (completedAttempts === attempts) { - requestsProvider.emit(resolvedKey, { - requestCallId, - online: false, - error: true - }) - if (inDeps('online')) setOnline(false) + if (inDeps('online')) setOnline(false) + } } } - } - }, getMiliseconds(attemptInterval as TimeSpan)) + }, + getMiliseconds(attemptInterval as TimeSpan) + ) return () => { clearTimeout(tm) @@ -1274,7 +1300,7 @@ export function useFetch< ]) const initializeRevalidation = useCallback( - windowExists + windowExists || streaming ? async function initializeRevalidation() { let d = undefined if (canRevalidate) { @@ -1320,7 +1346,8 @@ export function useFetch< def, resolvedDataKey, resolvedKey, - initialDataValue + initialDataValue, + streaming ] ) @@ -1349,7 +1376,9 @@ export function useFetch< } if (auto && canRevalidate && url !== '') { - initializeRevalidation() + if (!suspense) { + initializeRevalidation() + } } } @@ -1391,20 +1420,18 @@ export function useFetch< throw suspenseRevalidationStarted.get(resolvedKey) } - } - - if (!hasInitialOrFallbackData) { - throw new Error( - `Request with id "${id}" uses suspense but no fallback data was provided for SSR. See https://httpr.vercel.app/docs/fetch_config/defaults. - -If you want to use suspense without fallback data, wrap with the component from 'http-react', which renders client-side and the fallback ui server-side: - -import { Suspense } from "http-react" - -Learn more: https://httpr.vercel.app/docs/api#suspense - - ` - ) + } else { + if (streaming) { + if (!suspenseInitialized.get(resolvedKey)) { + if (!suspenseRevalidationStarted.get(resolvedKey)) { + suspenseRevalidationStarted.set( + resolvedKey, + initializeRevalidation() + ) + } + throw suspenseRevalidationStarted.get(resolvedKey) + } + } } } } @@ -1465,10 +1492,10 @@ Learn more: https://httpr.vercel.app/docs/api#suspense ? $requestEnd : null : maxAge === 0 - ? null - : notNull(cacheProvider.get('expiration' + resolvedDataKey)) - ? new Date(cacheProvider.get('expiration' + resolvedDataKey)) - : null + ? null + : notNull(cacheProvider.get('expiration' + resolvedDataKey)) + ? new Date(cacheProvider.get('expiration' + resolvedDataKey)) + : null const isFailed = hasErrors.get(resolvedDataKey) || hasErrors.get(resolvedKey) || error diff --git a/src/types/index.ts b/src/types/index.ts index 001ee30..0ad0be8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,29 +1,29 @@ export type HTTP_METHODS = - | "GET" - | "DELETE" - | "HEAD" - | "OPTIONS" - | "POST" - | "PUT" - | "PATCH" - | "PURGE" - | "LINK" - | "UNLINK"; + | 'GET' + | 'DELETE' + | 'HEAD' + | 'OPTIONS' + | 'POST' + | 'PUT' + | 'PATCH' + | 'PURGE' + | 'LINK' + | 'UNLINK' export type FetchContextType = { - clientOnly?: boolean; + clientOnly?: boolean fetcher?( url: string, config: FetchConfigType ): Promise<{ - json?: any; - data?: any; - status?: number; - blob?: any; - text?: any; - }>; - headers?: any; - baseUrl?: string; + json?: any + data?: any + status?: number + blob?: any + text?: any + }> + headers?: any + baseUrl?: string /** * Sets the default (placeholder) value of request data. * @@ -37,63 +37,64 @@ export type FetchContextType = { * } */ value?: { - [key: string]: any; - }; + [key: string]: any + } defaults?: { [key: string]: { /** * The `id` passed to the request */ - id?: any; + id?: any /** * Default value for this request */ - value?: any; - method?: HTTP_METHODS; - }; - }; - suspense?: any[]; - resolver?: (r: Response) => any; - middleware?(incomindgData: any, previousData: any): any; - transform?(fetchData: any): any; - children?: any; - auto?: boolean; - memory?: boolean; - refresh?: TimeSpan; - attempts?: number; - attemptInterval?: TimeSpan; - revalidateOnFocus?: boolean; - query?: any; - params?: any; - onOnline?: (e: { cancel: () => void }) => void; - onOffline?: () => void; - online?: boolean; - retryOnReconnect?: boolean; - cacheProvider?: CacheStoreType; - revalidateOnMount?: boolean; - cacheIfError?: boolean; + value?: any + method?: HTTP_METHODS + } + } + suspense?: any[] + streaming?: boolean + resolver?: (r: Response) => any + middleware?(incomindgData: any, previousData: any): any + transform?(fetchData: any): any + children?: any + auto?: boolean + memory?: boolean + refresh?: TimeSpan + attempts?: number + attemptInterval?: TimeSpan + revalidateOnFocus?: boolean + query?: any + params?: any + onOnline?: (e: { cancel: () => void }) => void + onOffline?: () => void + online?: boolean + retryOnReconnect?: boolean + cacheProvider?: CacheStoreType + revalidateOnMount?: boolean + cacheIfError?: boolean onFetchStart?( req: Request, config: FetchConfigType, ctx: FetchContextType - ): void; + ): void onFetchEnd?( res: Response, config: FetchConfigType, ctx: FetchContextType - ): void; - maxCacheAge?: TimeSpan; -} & Omit; + ): void + maxCacheAge?: TimeSpan +} & Omit export type CacheStoreType = { - get(k?: any): any; - set(k?: any, v?: any): any; - remove?(k?: any): any; -}; + get(k?: any): any + set(k?: any, v?: any): any + remove?(k?: any): any +} -export type CustomResponse = Omit & { - json(): Promise; -}; +export type CustomResponse = Omit & { + json(): Promise +} export type RequestWithBody = ( /** @@ -103,75 +104,75 @@ export type RequestWithBody = ( /** * The request configuration */ - reqConfig?: Omit, "suspense"> & { + reqConfig?: Omit, 'suspense'> & { /** * Default value */ - default?: R; + default?: R /** * Request query */ - query?: any; + query?: any /** * The function that formats the body */ - formatBody?: any; + formatBody?: any /** * Request params (like Express) */ - params?: any; + params?: any /** * The function that returns the resolved data */ - resolver?: (r: CustomResponse) => any; + resolver?: (r: CustomResponse) => any /** * A function that will run when the request fails */ - onError?(error: Error): void; + onError?(error: Error): void /** * A function that will run when the request completes succesfuly */ - onResolve?(data: R, res: CustomResponse): void; - cacheProvider?: CacheStoreType; + onResolve?(data: R, res: CustomResponse): void + cacheProvider?: CacheStoreType } ) => Promise<{ - error: any; - data: R; - config: RequestInit; - status: number; - res: CustomResponse; -}>; + error: any + data: R + config: RequestInit + status: number + res: CustomResponse +}> export type TimeSpan = | number - | `${string} ${"ms" | "sec" | "min" | "h" | "d" | "we" | "mo" | "y"}`; + | `${string} ${'ms' | 'sec' | 'min' | 'h' | 'd' | 'we' | 'mo' | 'y'}` /** * An imperative version of the `useFetch` hook */ export type ImperativeFetch = { - get: RequestWithBody; - delete: RequestWithBody; - head: RequestWithBody; - options: RequestWithBody; - post: RequestWithBody; - put: RequestWithBody; - patch: RequestWithBody; - purge: RequestWithBody; - link: RequestWithBody; - unlink: RequestWithBody; - config?: FetchContextType & FetchInit; -}; + get: RequestWithBody + delete: RequestWithBody + head: RequestWithBody + options: RequestWithBody + post: RequestWithBody + put: RequestWithBody + patch: RequestWithBody + purge: RequestWithBody + link: RequestWithBody + unlink: RequestWithBody + config?: FetchContextType & FetchInit +} export type FetchConfigType = Omit< RequestInit, - "body" | "headers" + 'body' | 'headers' > & { - headers?: any; + headers?: any /** * The fetch key */ - key?: any; + key?: any /** * The middleware function should return the data that will be commited to the state. It can be used for pagination, logging, etc. * @@ -180,58 +181,58 @@ export type FetchConfigType = Omit< middleware?( incomindgData: FetchDataType, previousData: FetchDataType - ): FetchDataType; - transform?(fetchData: FetchDataType): TransformData; + ): FetchDataType + transform?(fetchData: FetchDataType): TransformData fetcher?( url: string, config: FetchConfigType ): Promise<{ - json?: any; - data?: FetchDataType; - status?: number; - blob?: any; - text?: any; - }>; - body?: any; + json?: any + data?: FetchDataType + status?: number + blob?: any + text?: any + }> + body?: any /** * Any serializable id. This is optional. */ - id?: any; + id?: any /** * url of the resource to fetch */ - url?: string; + url?: string /** * Default data value */ - default?: FetchDataType; + default?: FetchDataType /** * Refresh interval (in seconds) to re-fetch the resource * @default 0 */ - refresh?: TimeSpan; + refresh?: TimeSpan /** * This will prevent automatic requests. * By setting this to `false`, requests will * only be made by calling `reFetch()` * @default true */ - auto?: boolean; + auto?: boolean /** * Responses are saved in memory and used as default data. * If `false`, the `default` prop will be used instead. * @default true */ - memory?: boolean; - onSubmit?: "reset" | ((form: HTMLFormElement, data: FormData) => void); + memory?: boolean + onSubmit?: 'reset' | ((form: HTMLFormElement, data: FormData) => void) /** * Function to run when request is resolved succesfuly */ - onResolve?: (data: FetchDataType, res?: Response) => void; + onResolve?: (data: FetchDataType, res?: Response) => void /** * Override the cache for this specific request */ - cacheProvider?: CacheStoreType; + cacheProvider?: CacheStoreType /** * Function to run when data is mutated */ @@ -241,23 +242,23 @@ export type FetchConfigType = Omit< * An imperative version of `useFetche` */ fetcher: ImperativeFetch - ) => void; + ) => void /** * Function to run when the request fails */ - onError?: (error: Error, req?: Response) => void; + onError?: (error: Error, req?: Response) => void /** * Function to run when a request is aborted */ - onAbort?: () => void; + onAbort?: () => void /** * Whether a change in deps will cancel a queued request and make a new one */ - cancelOnChange?: boolean; + cancelOnChange?: boolean /** * Parse as json by default */ - resolver?: (d: CustomResponse) => any; + resolver?: (d: CustomResponse) => any /** * The ammount of attempts if request fails * @default 1 @@ -265,21 +266,21 @@ export type FetchConfigType = Omit< attempts?: | number | ((q: { - status: number; - res: Response; - error: Error; - completedAttempts: number; - }) => number | undefined | void); + status: number + res: Response + error: Error + completedAttempts: number + }) => number | undefined | void) /** * The interval at which to run attempts on request fail * @default 0 */ - attemptInterval?: TimeSpan; + attemptInterval?: TimeSpan /** * If a request should be made when the tab is focused. This currently works on browsers * @default false */ - revalidateOnFocus?: boolean; + revalidateOnFocus?: boolean /** * If `false`, revalidation will only happen when props passed to the `useFetch` change. * For example, you may want to have a component that should @@ -292,77 +293,83 @@ export type FetchConfigType = Omit< * Note that the behaviour when props change is the same. * @default true */ - revalidateOnMount?: boolean; + revalidateOnMount?: boolean /** * This will run when connection is interrupted */ - onOffline?: () => void; + onOffline?: () => void /** * This will run when connection is restored */ - onOnline?: (e: { cancel: () => void }) => void; + onOnline?: (e: { cancel: () => void }) => void /** * If the request should retry when connection is restored * @default true */ - retryOnReconnect?: boolean; + retryOnReconnect?: boolean /** * If using inside a `` */ - suspense?: boolean; + suspense?: boolean + /** + * If `true` and `suspense` is enabled, the request will be fetched and streamed from the server during SSR. + * If `false` or omitted, suspense will render the fallback during SSR and fetch on the client. + * @default false + */ + streaming?: boolean /** * Override base url */ - baseUrl?: string; + baseUrl?: string /** * Request method */ - method?: HTTP_METHODS; + method?: HTTP_METHODS /** * URL search params */ - query?: any; + query?: any /** * URL params */ - params?: any; + params?: any /** * Customize how body is formated for the request. By default it will be sent in JSON format * but you can set it to false if for example, you are sending a `FormData` * body, or to `b => serialize(b)` for example, if you want to send JSON data * (the last one is the default behaviour so in that case you can ignore it) */ - formatBody?: boolean | ((b: any) => any); + formatBody?: boolean | ((b: any) => any) /** * The time to wait before revalidation after props change */ - debounce?: TimeSpan; + debounce?: TimeSpan /** * Will run when the request is sent */ - onFetchStart?: FetchContextType["onFetchStart"]; + onFetchStart?: FetchContextType['onFetchStart'] /** * Will run when the response is received */ - onFetchEnd?: FetchContextType["onFetchEnd"]; + onFetchEnd?: FetchContextType['onFetchEnd'] /** * If `true`, the last resolved value be returned as `data` if the request fails. If `false`, the default value will be returned instead * * @default true */ - cacheIfError?: boolean; + cacheIfError?: boolean /** * The max age a page should be cached (with no request) */ - maxCacheAge?: TimeSpan; -}; + maxCacheAge?: TimeSpan +} // If first argument is a string export type FetchConfigTypeNoUrl< FetchDataType = any, TransformData = any -> = Omit, "url">; +> = Omit, 'url'> /** * Create a configuration object to use in a 'useFetche' call @@ -370,7 +377,7 @@ export type FetchConfigTypeNoUrl< export type FetchInit = FetchConfigType< FDT, TransformData ->; +> // types related to params parsing @@ -379,18 +386,18 @@ export type ExtractParam = Segment extends `[${infer Name}]` ? Name // [name] : Segment extends `:${infer Name}` - ? Name // :name - : Segment extends `{${infer Name}}` - ? Name // {name} - : never; + ? Name // :name + : Segment extends `{${infer Name}}` + ? Name // {name} + : never type CleanPath = Path extends `/${infer Rest}` ? CleanPath : Path extends `${infer Rest}/` - ? CleanPath - : Path extends `${infer Base}?${any}` - ? CleanPath - : Path; + ? CleanPath + : Path extends `${infer Base}?${any}` + ? CleanPath + : Path type ParsePathParams = Path extends `${infer Segment}/${infer Rest}` @@ -399,25 +406,25 @@ type ParsePathParams = : { [K in ExtractParam]: string | number }) & ParsePathParams : ExtractParam extends never - ? {} - : { [K in ExtractParam]: string | number }; + ? {} + : { [K in ExtractParam]: string | number } -export type PathParams = ParsePathParams>; +export type PathParams = ParsePathParams> export type StaticParams = PathParams extends Record ? { params?: any } - : { params: PathParams }; + : { params: PathParams } export type StaticFetchConfig = Omit< FetchConfigType, - "url" | "params" + 'url' | 'params' > & { - url?: U; -} & StaticParams; + url?: U +} & StaticParams export type StaticFetchConfigNoUrl = Omit< FetchConfigTypeNoUrl, - "params" + 'params' > & - StaticParams; + StaticParams diff --git a/tsconfig.json b/tsconfig.json index 9737e95..07a62f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,13 +2,13 @@ "compilerOptions": { "target": "ES2015", "outDir": "./dist", - "module": "CommonJS", + "module": "node16", "lib": ["es6", "dom", "es2019"], "sourceMap": false, "allowJs": true, "jsx": "react-jsx", "strict": false, - "moduleResolution": "node", + "moduleResolution": "node16", "esModuleInterop": true, "rootDir": "src", "noImplicitReturns": true,