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!!
}