From f9f373916f1879cd17f54011e04bba927fc0df00 Mon Sep 17 00:00:00 2001 From: Exidex <16986685+Exidex@users.noreply.github.com> Date: Thu, 12 Sep 2024 20:25:28 +0200 Subject: [PATCH] Add useCachedPromise hook --- dev_plugin/gauntlet.toml | 7 + dev_plugin/src/hooks-view.tsx | 372 ++++++++++++++++++++++++++++++ dev_plugin/src/list-view.tsx | 208 +---------------- js/api/src/hooks.ts | 126 +++++++--- js/react_renderer/src/renderer.ts | 8 +- 5 files changed, 483 insertions(+), 238 deletions(-) create mode 100644 dev_plugin/src/hooks-view.tsx diff --git a/dev_plugin/gauntlet.toml b/dev_plugin/gauntlet.toml index b36fefd..6741b0c 100644 --- a/dev_plugin/gauntlet.toml +++ b/dev_plugin/gauntlet.toml @@ -107,6 +107,13 @@ path = 'src/list-view.tsx' type = 'view' description = '' +[[entrypoint]] +id = 'hooks-view' +name = 'Hooks view' +path = 'src/hooks-view.tsx' +type = 'view' +description = '' + [[entrypoint]] id = 'command-a' name = 'Command A' diff --git a/dev_plugin/src/hooks-view.tsx b/dev_plugin/src/hooks-view.tsx new file mode 100644 index 0000000..4099dbf --- /dev/null +++ b/dev_plugin/src/hooks-view.tsx @@ -0,0 +1,372 @@ +import { Icons, List } from "@project-gauntlet/api/components"; +import React, { ReactElement, useRef } from "react"; +import { useCachedPromise, useNavigation, usePromise } from "@project-gauntlet/api/hooks"; + +export default function ListView(): ReactElement { + const { pushView } = useNavigation(); + + return ( + { + switch (id) { + case "UsePromiseTestBasic": { + pushView() + break; + } + case "UsePromiseTestExecuteFalse": { + pushView() + break; + } + case "UsePromiseTestRevalidate": { + pushView() + break; + } + case "UsePromiseTestAbortableRevalidate": { + pushView() + break; + } + case "UsePromiseTestMutate": { + pushView() + break; + } + case "UsePromiseTestMutateOptimistic": { + pushView() + break; + } + case "UsePromiseTestMutateOptimisticRollback": { + pushView() + break; + } + case "UsePromiseTestMutateNoRevalidate": { + pushView() + break; + } + case "UsePromiseTestThrow": { + pushView() + break; + } + case "UseCachedPromiseBasic": { + pushView() + break; + } + case "UseCachedPromiseInitialState": { + pushView() + break; + } + } + }} + > + + + + + + + + + + + + + ) +} + +function UsePromiseTestBasic(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3] + ); + + printState(data, error, isLoading) + + return ( + + + + ) +} + +function UseCachedPromiseBasic(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading } = useCachedPromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3] + ); + + printState(data, error, isLoading) + + return ( + + + + + + ) +} + +function UseCachedPromiseInitialState(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading } = useCachedPromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + { + initialState: () => "initial" + } + ); + + printState(data, error, isLoading) + + return ( + + + + + + ) +} + +function UsePromiseTestExecuteFalse(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + { + execute: false + } + ); + + printState(data, error, isLoading) + + return ( + + + + ) +} + +function UsePromiseTestRevalidate(): ReactElement { + const { popView } = useNavigation(); + + const { data, error, isLoading, revalidate } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + revalidate())} + > + + + + ) +} + +function UsePromiseTestAbortableRevalidate(): ReactElement { + const { popView } = useNavigation(); + const abortable = useRef(); + + const { data, error, isLoading, revalidate } = usePromise( + async (one, two, three) => { + await inNSec(5) + }, + [1, 2, 3], + { + abortable, + } + ); + + printState(data, error, isLoading) + + return ( + revalidate())} + > + + + + ) +} + +function UsePromiseTestMutate(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading, mutate } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + { + await mutate(inNSec(5)) + })} + > + + + + ) +} + +function UsePromiseTestMutateOptimistic(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading, mutate } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + { + await mutate( + inNSec(5), + { + optimisticUpdate: data1 => data1 + " optimistic", + } + ) + })} + > + + + + ) +} + +function UsePromiseTestMutateOptimisticRollback(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading, mutate } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + { + await mutate( + new Promise((_resolve, reject) => { + setTimeout( + () => { + reject("fail") + }, + 5 * 1000 + ); + }), + { + optimisticUpdate: data1 => data1 + " optimistic", + rollbackOnError: data1 => data1 + " failed", + } + ); + })} + > + + + + ) +} + +function UsePromiseTestMutateNoRevalidate(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading, mutate } = usePromise( + async (one, two, three) => await inNSec(5), + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + { + await mutate( + inNSec(5), + { + shouldRevalidateAfter: false, + } + ) + })} + > + + + + ) +} + +function UsePromiseTestThrow(): ReactElement { + const { popView } = useNavigation(); + const { data, error, isLoading } = usePromise( + async (one, two, three) => { + throw new Error("test") + }, + [1, 2, 3], + ); + + printState(data, error, isLoading) + + return ( + + + + ) +} + +function onSelectionChangeHandler(popView: () => void, handler?: () => void): (id: string) => void { + return (id) => { + switch (id) { + case "back": { + popView() + break; + } + case "run": { + handler?.() + break; + } + } + } +} + +async function inNSec(n: number): Promise { + return new Promise(resolve => { + setTimeout( + () => { + resolve(`Value: ${Math.random()}`) + }, + n * 1000 + ); + }) +} + +function printState(data: any, error: unknown, isLoading: boolean) { + console.log("") + console.log("=====") + console.dir(data) + console.dir(error) + console.dir(isLoading) +} \ No newline at end of file diff --git a/dev_plugin/src/list-view.tsx b/dev_plugin/src/list-view.tsx index c5ac1bb..ac0f04a 100644 --- a/dev_plugin/src/list-view.tsx +++ b/dev_plugin/src/list-view.tsx @@ -1,18 +1,7 @@ import { Icons, List } from "@project-gauntlet/api/components"; -import { ReactElement, useRef, useState } from "react"; -import { usePromise } from "@project-gauntlet/api/hooks"; +import { ReactElement, useState } from "react"; export default function ListView(): ReactElement { - // return usePromiseTestBasic() - // return usePromiseTestExecuteFalse() - // return usePromiseTestRevalidate() - // return usePromiseTestAbortableRevalidate() - // return usePromiseTestMutate() - // return usePromiseTestMutateOptimistic() - // return usePromiseTestMutateOptimisticRollback() - // return usePromiseTestMutateNoRevalidate() - // return usePromiseTestThrow() - const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; const [id, setId] = useState("default"); @@ -38,199 +27,4 @@ export default function ListView(): ReactElement { ) -} - -function usePromiseTestBasic(): ReactElement { - const { data, error, isLoading } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3] - ); - - printState(data, error, isLoading) - - return ( - {}}> - - - ) -} - -function usePromiseTestExecuteFalse(): ReactElement { - const { data, error, isLoading } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - { - execute: false - } - ); - - printState(data, error, isLoading) - - return ( - {}}> - - - ) -} - -function usePromiseTestRevalidate(): ReactElement { - const { data, error, isLoading, revalidate } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - revalidate()}> - - - ) -} - -function usePromiseTestAbortableRevalidate(): ReactElement { - const abortable = useRef(); - - const { data, error, isLoading, revalidate } = usePromise( - async (one, two, three) => { - await inNSec(5) - }, - [1, 2, 3], - { - abortable, - } - ); - - printState(data, error, isLoading) - - return ( - revalidate()}> - - - ) -} - -function usePromiseTestMutate(): ReactElement { - const { data, error, isLoading, mutate } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - await mutate(inNSec(5))}> - - - ) -} - -function usePromiseTestMutateOptimistic(): ReactElement { - const { data, error, isLoading, mutate } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - { - await mutate( - inNSec(5), - { - optimisticUpdate: data1 => data1 + " optimistic", - } - ) - }}> - - - ) -} - -function usePromiseTestMutateOptimisticRollback(): ReactElement { - const { data, error, isLoading, mutate } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - { - const newVar = await mutate( - new Promise((_resolve, reject) => { - setTimeout( - () => { - reject("fail") - }, - 5 * 1000 - ); - }), - { - optimisticUpdate: data1 => data1 + " optimistic", - rollbackOnError: data1 => data1 + " failed", - } - ); - }}> - - - ) -} - -function usePromiseTestMutateNoRevalidate(): ReactElement { - const { data, error, isLoading, mutate } = usePromise( - async (one, two, three) => await inNSec(5), - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - { - await mutate( - inNSec(5), - { - shouldRevalidateAfter: false, - } - ) - }}> - - - ) -} - -function usePromiseTestThrow(): ReactElement { - const { data, error, isLoading } = usePromise( - async (one, two, three) => { - throw new Error("test") - }, - [1, 2, 3], - ); - - printState(data, error, isLoading) - - return ( - {}}> - - - ) -} - -async function inNSec(n: number): Promise { - return new Promise(resolve => { - setTimeout( - () => { - resolve(`Promise resolved after ${n} sec: ${Math.random()}`) - }, - n * 1000 - ); - }) -} - -function printState(data: any, error: unknown, isLoading: boolean) { - console.log("") - console.log("=====") - console.dir(data) - console.dir(error) - console.dir(isLoading) } \ No newline at end of file diff --git a/js/api/src/hooks.ts b/js/api/src/hooks.ts index 94dde6f..1073c9d 100644 --- a/js/api/src/hooks.ts +++ b/js/api/src/hooks.ts @@ -1,4 +1,4 @@ -import { ReactNode, useRef, useState, useCallback, useEffect, MutableRefObject, Dispatch, SetStateAction } from 'react'; +import { ReactNode, useRef, useId, useState, useCallback, useEffect, MutableRefObject, Dispatch, SetStateAction } from 'react'; // @ts-ignore TODO how to add declaration for this? import { useGauntletContext } from "gauntlet:renderer"; @@ -47,38 +47,110 @@ export type MutatePromiseFn = ( }, ) => Promise; +export type UsePromiseOptions Promise> = { + abortable?: MutableRefObject; + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: Awaited>) => void; + onWillExecute?: (...args: Parameters) => void; +} + export function usePromise Promise, R>( fn: T, args?: Parameters, - options?: { - abortable?: MutableRefObject; - execute?: boolean; - onError?: (error: unknown) => void; - onData?: (data: Awaited>) => void; - onWillExecute?: (...args: Parameters) => void; - }, + options?: UsePromiseOptions, +): AsyncState>> & { + revalidate: () => void; + mutate: MutatePromiseFn>, R>; +} { + const execute = options?.execute !== false; // execute by default + + const [state, setState] = useState>>>({ isLoading: execute }); + + return usePromiseInternal( + fn, + state, + setState, + args || ([] as any), + execute, + options?.abortable, + options?.onError, + options?.onData, + options?.onWillExecute + ) +} + +export function useCachedPromise Promise, R>( + fn: T, + args?: Parameters, + options?: UsePromiseOptions & { initialState?: Awaited> | (() => Awaited>) }, +): AsyncState>> & { + revalidate: () => void; + mutate: MutatePromiseFn>, R>; +} { + const execute = options?.execute !== false; // execute by default + + const id = useId(); + + const { entrypointId }: { entrypointId: () => string } = useGauntletContext(); + + // same store is fetched and updated between command runs + const [state, setState] = useCache>>>("useCachedPromise" + entrypointId() + id, () => { + const initialState = options?.initialState; + if (initialState) { + if (initialState instanceof Function) { + return { isLoading: execute, data: initialState() } + } else { + return { isLoading: execute, data: initialState } + } + } else { + return { isLoading: execute } + } + }); + + return usePromiseInternal( + fn, + state, + setState, + args || ([] as any), + execute, + options?.abortable, + options?.onError, + options?.onData, + options?.onWillExecute + ) +} + +function usePromiseInternal Promise, R>( + fn: T, + state: AsyncState>>, + setState: Dispatch>>>>, + args: Parameters, + execute: boolean, + abortable?: MutableRefObject, + onError?: (error: unknown) => void, + onData?: (data: Awaited>) => void, + onWillExecute?: (...args: Parameters) => void, ): AsyncState>> & { revalidate: () => void; // will execute even if options.execute is false mutate: MutatePromiseFn>, R>; // will execute even if options.execute is false } { - const execute = options?.execute !== false; // execute by default const promiseRef = useRef>(); - const [state, setState] = useState>>>({ isLoading: execute }); useEffect(() => { return () => { - options?.abortable?.current?.abort(); + abortable?.current?.abort(); }; - }, [options?.abortable]); + }, [abortable]); const callback = useCallback(async (...args: Parameters): Promise => { - if (options && options.abortable) { - options.abortable.current?.abort(); - options.abortable.current = new AbortController() + if (abortable) { + abortable.current?.abort(); + abortable.current = new AbortController() } - options?.onWillExecute?.(...args); + onWillExecute?.(...args); const promise = fn(...args); @@ -95,11 +167,11 @@ export function usePromise Promise, R>( if (promise === promiseRef.current) { setState({ error, isLoading: false }) - if (options && options.abortable) { - options.abortable.current = undefined; + if (abortable) { + abortable.current = undefined; } - options?.onError?.(error); + onError?.(error); } return } @@ -109,23 +181,23 @@ export function usePromise Promise, R>( if (promise === promiseRef.current) { setState({ data: promiseResult, isLoading: false }); - if (options && options.abortable) { - options.abortable.current = undefined; + if (abortable) { + abortable.current = undefined; } - options?.onData?.(promiseResult) + onData?.(promiseResult) } - }, args || []); + }, args); useEffect(() => { if (execute) { - callback(...(args || ([] as any))); + callback(...args); } }, [callback, execute]); return { revalidate: () => { - callback(...(args || ([] as any))); + callback(...args); }, mutate: async ( asyncUpdate: Promise, @@ -149,7 +221,7 @@ export function usePromise Promise, R>( const asyncUpdateResult = await asyncUpdate; if (shouldRevalidateAfter) { - callback(...(args || ([] as any))); + callback(...args); } else { // set loading false, only when not revalidating, because revalidate will unset it itself setState(prevState => ({ ...prevState, isLoading: false })); @@ -191,7 +263,7 @@ export function usePromise Promise, R>( const asyncUpdateResult = await asyncUpdate; if (shouldRevalidateAfter) { - callback(...(args || ([] as any))); + callback(...args); } else { // set loading false, only when not revalidating, because revalidate will unset it itself setState(prevState => ({ ...prevState, isLoading: false })); diff --git a/js/react_renderer/src/renderer.ts b/js/react_renderer/src/renderer.ts index 6e35e20..a1331ee 100644 --- a/js/react_renderer/src/renderer.ts +++ b/js/react_renderer/src/renderer.ts @@ -52,19 +52,19 @@ class GauntletContextValue { this._navStack.push(view) } - renderLocation(): RenderLocation { + renderLocation = (): RenderLocation => { return this._renderLocation!! } - isBottommostView() { + isBottommostView = () => { return this._navStack.length === 1 } - topmostView() { + topmostView = () => { return this._navStack[this._navStack.length - 1] } - entrypointId() { + entrypointId = () => { return this._entrypointId!! }