From 28dc2d3136bab70cd684849962728408a293ce2f Mon Sep 17 00:00:00 2001 From: Ian K Smith Date: Fri, 29 Jul 2022 12:12:44 -0600 Subject: [PATCH] breaking API changes to useAsyncEffect --- docs/use-async-effect.md | 46 +++++++------- docs/use-hash.md | 2 +- src/hooks/use-async-effect/index.ts | 93 +++++++++-------------------- src/hooks/use-dom-event/index.ts | 32 ++++++---- 4 files changed, 76 insertions(+), 97 deletions(-) diff --git a/docs/use-async-effect.md b/docs/use-async-effect.md index 6c02737..e269613 100644 --- a/docs/use-async-effect.md +++ b/docs/use-async-effect.md @@ -6,25 +6,31 @@ Enables asynchronous effects with some guardrails to protect against memory-leak ## Example ```tsx -useAsyncEffect(async (ctx) => { - ctx.hello = 'world'; // Attach anything you like to the context object! - return fetch('...').then(res => res.json()); -}, [/* effect dependencies */]) -.fulfilled((value, ctx) => { - // Equivalent to `Promise.then` - console.log(value); // Do something with your JSON! - console.log(ctx.hello) // => 'world' - }) -.rejected((reason, ctx) => { - // Equivalent to `Promise.catch` - // ... - }) -.settled((ctx) => { - // Equivalent to `Promise.finally` - // ... - }) -.cleanup((ctx) => { - // Defines the same behavior as a function returned by `useEffect` - // ... +useAsyncEffect(() => { + return { + execute: async (signal) => { + return fetch('...', { signal }).then(res => res.json()); + }, + + onFulfilled: (value) => { + // Equivalent to `Promise.then` + console.log(value); // Do something stateful with your JSON! + }, + + onRejected: (reason) => { + // Equivalent to `Promise.catch` + // ... + }, + + onSettled: () => { + // Equivalent to `Promise.finally` + // ... + }, + + onCleanup: () => { + // Defines the same behavior as a function returned by `useEffect` + // ... + }, + } }); ``` diff --git a/docs/use-hash.md b/docs/use-hash.md index a0fedb3..389e560 100644 --- a/docs/use-hash.md +++ b/docs/use-hash.md @@ -1,6 +1,6 @@ # `useHash` -Returns an MD5-compatible hash of any value given to this hook as its argument. Most client-side structures are hashable based on their serialized contents. This is handy for translating complex objects into a primitive string value for accurately detecting updates between render ticks as a dependency given to `useEffect`. +Returns an MD5-compliant hash of any value given to this hook as its argument. Most client-side structures are hashable based on their serialized contents. This is handy for translating complex objects into a primitive string value for accurately detecting updates between renders as a dependency provided to `useEffect`. ## Example diff --git a/src/hooks/use-async-effect/index.ts b/src/hooks/use-async-effect/index.ts index deafc57..c8274b8 100644 --- a/src/hooks/use-async-effect/index.ts +++ b/src/hooks/use-async-effect/index.ts @@ -1,45 +1,35 @@ -import { DependencyList, useEffect, useCallback, useRef, useMemo } from 'react'; +import { DependencyList, useEffect, useMemo, useRef } from 'react'; import { useIsMounted } from '../use-is-mounted'; -type NonVoid = T extends void ? never : T; - -type NarrowedAsyncEffect = Omit< - AsyncEffect, - NonVoid | TNarrow ->; +export interface AsyncEffectInit { + /** + * Do something async! + */ + execute: (signal: AbortSignal) => Promise; -export interface AsyncEffect { /** * Registers a callback to execute when the async handler resolves. Analagous * to `Promise.then`. */ - fulfilled( - onfulfilled?: ((value: TResult, context: Partial) => void) | null, - ): NarrowedAsyncEffect; + onFulfilled?: ((value: ResultType) => void) | null; /** * Registers a callback to execute when the async handler rejects. Analagous * to `Promise.catch`. */ - rejected( - onrejected?: ((reason: any, context: Partial) => void) | null, - ): NarrowedAsyncEffect; + onRejected?: ((reason: any) => void) | null; /** * Registers a callback to execute when the async handler completes, whether * successfully or not. Analagous to `Promise.finally`. */ - settled( - onsettled?: ((context: Partial) => void) | null, - ): NarrowedAsyncEffect; + onSettled?: (() => void) | null; /** * Registers a callback to execute when the underlying `React.useEffect` is * cleaned up. */ - cleanup( - oncleanup?: ((context: Partial) => void) | null, - ): NarrowedAsyncEffect; + onCleanup?: (() => void) | null; } /** @@ -47,62 +37,33 @@ export interface AsyncEffect = Record, TResult = any>( - handler: (context: Partial) => Promise, +export function useAsyncEffect( + initFactory: () => AsyncEffectInit, deps?: DependencyList, -): AsyncEffect { - const thenCallback = useRef(); - const registerThenCb = useCallback((onfulfilled) => { - thenCallback.current = onfulfilled; - return chain; - }, []); - - const catchCallback = useRef(); - const registerCatchCb = useCallback((onrejected) => { - catchCallback.current = onrejected; - return chain; - }, []); - - const finallyCallback = useRef(); - const registerFinallyCb = useCallback((onsettled) => { - finallyCallback.current = onsettled; - return chain; - }, []); - - const cleanupCallback = useRef(); - const registerCleanupCb = useCallback((oncleanup) => { - cleanupCallback.current = oncleanup; - return chain; - }, []); - - const chain: any = useMemo(() => { - return { - fullfilled: registerThenCb, // backwards compat for spelling mistake - fulfilled: registerThenCb, - rejected: registerCatchCb, - settled: registerFinallyCb, - cleanup: registerCleanupCb, - }; - }, []); +): void { + const init = useMemo>(() => { + return initFactory(); + }, [deps]); const isMounted = useIsMounted(); useEffect(() => { - const context: any = {}; + const controller = new AbortController(); - handler(context) + init + .execute(controller.signal) .then((value) => { - if (isMounted() && thenCallback.current) thenCallback.current(value, context); + if (isMounted() && !controller.signal.aborted) init.onFulfilled?.(value); }) .catch((err) => { - if (isMounted() && catchCallback.current) catchCallback.current(err, context); + if (isMounted() && !controller.signal.aborted) init.onRejected?.(err); }) .finally(() => { - if (isMounted() && finallyCallback.current) finallyCallback.current(context); + if (isMounted() && !controller.signal.aborted) init.onSettled?.(); }); - if (cleanupCallback.current) return () => cleanupCallback.current(context); - return undefined; - }, deps); - - return chain; + return () => { + controller.abort(); + init.onCleanup?.(); + }; + }, [init]); } diff --git a/src/hooks/use-dom-event/index.ts b/src/hooks/use-dom-event/index.ts index 23dec52..3bd59eb 100644 --- a/src/hooks/use-dom-event/index.ts +++ b/src/hooks/use-dom-event/index.ts @@ -30,7 +30,7 @@ interface AddEventListenerFunction< } export type UseDomEventRemoveListenerFunction = () => void; -export type UseDomEventAddListenerFunction = T extends HTMLElement +export type UseDomEventAddListenerFunction = T extends HTMLElement ? AddEventListenerFunction : T extends Window ? AddEventListenerFunction @@ -40,13 +40,19 @@ export type UseDomEventAddListenerFunction( - element: T | MutableRefObject | RefObject, -) { + taget: T | MutableRefObject | RefObject, +): UseDomEventAddListenerFunction; +export function useDomEvent( + taget: () => T | MutableRefObject | RefObject, +): UseDomEventAddListenerFunction; +export function useDomEvent( + target: T | (() => T) | MutableRefObject | RefObject, +): UseDomEventAddListenerFunction { return ((...eventListenerParams: any[]) => { const [eventName, listener, depsOrOptions, optionsOrDeps] = eventListenerParams; @@ -68,15 +74,18 @@ export function useDomEvent( }, [options]); useEffect(() => { + const element = typeof target === 'function' ? target() : target; + // Bail out early if `element` is null. if (!element) return undefined; // Handle events from an `element` given as a valid node. if (isWindow(element) || isDocument(element) || isElement(element)) { + const options = typeof savedOptions.current === 'boolean' ? savedOptions.current : { ...savedOptions.current }; const listener = (e: any) => savedListener.current(e); - element.addEventListener(eventName, listener, savedOptions.current); + element.addEventListener(eventName, listener, options); removeListenerRef.current = () => { - element.removeEventListener(eventName, listener, savedOptions.current); + element.removeEventListener(eventName, listener, options); }; return removeListenerRef.current; } @@ -84,18 +93,21 @@ export function useDomEvent( // Handle events from an `element` given as a React ref. if (isRefObject(element)) { if (!!element.current && isElement(element.current)) { + const el = element.current; + const options = + typeof savedOptions.current === 'boolean' ? savedOptions.current : { ...savedOptions.current }; const listener = (e: any) => savedListener.current(e); - element.current.addEventListener(eventName, listener, savedOptions.current); + el.addEventListener(eventName, listener, options); removeListenerRef.current = () => { - element.current!.removeEventListener(eventName, listener, savedOptions.current); + el.removeEventListener(eventName, listener, options); }; return removeListenerRef.current; } } return undefined; - }, [eventName, element, ...deps]); + }, [eventName, typeof target !== 'function' && target, ...deps]); return useCallback(() => removeListenerRef.current(), []); - }) as UseDomEventAddListenerFunction>; + }) as UseDomEventAddListenerFunction; }