From 260f840cfb558737420140cefd2eb17447471c60 Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Fri, 26 Dec 2025 20:12:31 -0700 Subject: [PATCH 1/6] Add requests, useQueryState, and useMessageExpired --- jest.config.cjs | 3 + package-lock.json | 4 +- package.json | 2 +- src/events.ts | 83 +++++++- src/forms.ts | 182 +++++++++++++++++ src/index.tsx | 6 +- src/requests.ts | 56 ++++++ src/src.ts | 198 ++++++++++--------- src/types.ts | 104 +++++++--- test-apps/dogs-ts/package-lock.json | 2 +- test-apps/dogs-ts/src/App.css | 8 + test-apps/dogs-ts/src/App.tsx | 27 ++- test-apps/dogs-ts/src/db.ts | 6 +- test-apps/nextjs-dashboard/package-lock.json | 2 +- test-apps/nextjs-persist/package-lock.json | 2 +- test-apps/parallel-queries/package-lock.json | 2 +- test-apps/persist/package-lock.json | 2 +- test-apps/todos/package-lock.json | 2 +- tests/src.test.ts | 20 ++ 19 files changed, 562 insertions(+), 149 deletions(-) create mode 100644 src/forms.ts create mode 100644 src/requests.ts diff --git a/jest.config.cjs b/jest.config.cjs index 8cbf894..1106fa6 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -2,4 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testPathIgnorePatterns: [ + '/\\.yalc/' + ], }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 39b3de3..1b47044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "leo-query", - "version": "0.5.0", + "version": "0.6.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "leo-query", - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/package.json b/package.json index b311edc..ed32810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "leo-query", - "version": "0.5.0", + "version": "0.6.0-rc.0", "description": "A simple library to connect async queries to Zustand stores.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/events.ts b/src/events.ts index dedae13..d8daac3 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,4 +1,5 @@ -import {ErrorPayload, SuccessPayload, SettledPayload, LeoQueryEventTarget} from "./types"; +import {RequestPayload, LeoQueryEventTarget, Effect, Query} from "./types"; +import {isEffect, isQuery} from "./src"; import {StoreApi, UseBoundStore} from "zustand"; interface StoreEvent { @@ -6,16 +7,28 @@ interface StoreEvent { eventTarget: LeoQueryEventTarget; } +interface EffectEvent { + effect: Effect; + eventTarget: LeoQueryEventTarget; +} + +interface QueryEvent { + query: Query; + eventTarget: LeoQueryEventTarget; +} + const storeEvents: StoreEvent[] = []; +const effectEvents: EffectEvent[] = []; +const queryEvents: QueryEvent[] = []; -export function createEvents(store: UseBoundStore>): LeoQueryEventTarget { +function createEventsFromStore(store: UseBoundStore>): LeoQueryEventTarget { const e = new EventTarget(); const ee = { - addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => e.addEventListener(type, listener as EventListener, options), - removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => e.removeEventListener(type, listener as EventListener, options), - __dispatchEvent: (evt: CustomEvent) => { + __dispatchEvent: (evt: CustomEvent) => { e.dispatchEvent(evt); }, } as LeoQueryEventTarget; @@ -23,22 +36,74 @@ export function createEvents(store: UseBoundStore>): LeoQueryEven return ee; }; + +function createEventsFromEffect(effect: Effect): LeoQueryEventTarget { + const e = new EventTarget(); + const ee = { + addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + e.addEventListener(type, listener as EventListener, options), + removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + e.removeEventListener(type, listener as EventListener, options), + __dispatchEvent: (evt: CustomEvent) => { + e.dispatchEvent(evt); + }, + } as LeoQueryEventTarget; + effectEvents.push({effect, eventTarget: ee}); + return ee; +} + +function createEventsFromQuery(query: Query): LeoQueryEventTarget { + const e = new EventTarget(); + const ee = { + addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + e.addEventListener(type, listener as EventListener, options), + removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + e.removeEventListener(type, listener as EventListener, options), + } as LeoQueryEventTarget; + queryEvents.push({query, eventTarget: ee}); + return ee; +} + +export function createEvents(store: UseBoundStore>): LeoQueryEventTarget; +export function createEvents(effect: Effect): LeoQueryEventTarget; +export function createEvents(query: Query): LeoQueryEventTarget; +export function createEvents(arg: any): LeoQueryEventTarget { + if (isEffect(arg)) { + return createEventsFromEffect(arg); + } else if (isQuery(arg)) { + return createEventsFromQuery(arg); + } else { + return createEventsFromStore(arg); + } + +} + function createGlobalEventTarget(): LeoQueryEventTarget { const events = new EventTarget(); return { - addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + addEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => events.addEventListener(type, listener as EventListener, options), - removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => + removeEventListener: (type: string, listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean) => events.removeEventListener(type, listener as EventListener, options), - __dispatchEvent: (evt: CustomEvent) => { + __dispatchEvent: (evt: CustomEvent) => { const store = evt.detail.effect?.__store() ?? evt.detail.query?.__store(); events.dispatchEvent(evt); storeEvents.forEach(s => { if (s.store === store) { s.eventTarget.__dispatchEvent(evt); } - }) + }); + effectEvents.forEach(e => { + if (e.effect.__id === evt.detail.effect?.__id) { + e.eventTarget.__dispatchEvent(evt); + } + }); + queryEvents.forEach(q => { + if (q.query.__id === evt.detail.query?.__id) { + q.eventTarget.__dispatchEvent(evt); + } + }); } } as LeoQueryEventTarget; }; diff --git a/src/forms.ts b/src/forms.ts new file mode 100644 index 0000000..e1aad2f --- /dev/null +++ b/src/forms.ts @@ -0,0 +1,182 @@ +import { QueryValue, LeoRequest } from "./types"; +import { useState, useEffect, Dispatch, SetStateAction, useRef } from "react"; +import { equals } from "./src"; + +/** + * React hook that creates local state synchronized with a query's value. + * Useful for form inputs or other UI components that need local state management tied to a query. + * The local state will automatically update when the query's value changes. + * + * @param query - The query value object to synchronize state with. + * @param initialValue - Initial value for the local state. Guarantees non-undefined state. + * @returns A tuple containing the current state value and a setter function, similar to React's `useState`. + * + * @example + * ```tsx + * const [name, setName] = useQueryState(userQuery, ''); + * + * return ( + * setName(e.target.value)} + * /> + * ); + * ``` + */ +export function useQueryState(query: QueryValue, initialValue: T): [T, Dispatch>]; +/** + * React hook that creates local state synchronized with a query's value. + * Maps a property from the query value using a selector function. + * + * @param query - The query value object to synchronize state with. + * @param selector - Function to extract a property from the query value. Only the selected property is managed locally. + * @returns A tuple containing the current state value and a setter function, similar to React's `useState`. + * + * @example + * ```tsx + * // Query returns { name: 'John', age: 30 } + * const [name, setName] = useQueryState(userQuery, (user) => user.name, ''); + * + * return ( + * setName(e.target.value)} + * /> + * ); + * ``` + */ +export function useQueryState(query: QueryValue, selector: (value: Q) => T): [T, Dispatch>]; +/** + * React hook that creates local state synchronized with a query's value. + * Maps a property from the query value using a selector function. + * + * @param query - The query value object to synchronize state with. + * @param selector - Function to extract a property from the query value. Only the selected property is managed locally. + * @param initialValue - Initial value for the local state. Guarantees non-undefined state. + * @returns A tuple containing the current state value and a setter function, similar to React's `useState`. + * + * @example + * ```tsx + * // Query returns { name: 'John', age: 30 } + * const [name, setName] = useQueryState(userQuery, (user) => user.name, ''); + * + * return ( + * setName(e.target.value)} + * /> + * ); + * ``` + */ +export function useQueryState(query: QueryValue, selector: (value: Q) => T, initialValue: T): [T, Dispatch>]; +/** + * React hook that creates local state synchronized with a query's value. + * + * @param query - The query value object to synchronize state with. + * @param selector - Optional function to extract a property from the query value. When provided, only the selected property is managed locally. + * @param initialValue - Optional initial value for the local state. + * @returns A tuple containing the current state value (may be undefined) and a setter function, similar to React's `useState`. + * + * @example + * ```tsx + * // Without initial value - state may be undefined + * const [name, setName] = useQueryState(userQuery, (user) => user.name); + * + * return ( + * setName(e.target.value)} + * /> + * ); + * ``` + */ +export function useQueryState(query: QueryValue, selector?: (value: Q) => T, initialValue?: T): [T | undefined, Dispatch>] { + let _selector: (value: Q) => T | undefined; + if (selector && typeof selector === 'function') { + _selector = selector; + } else { + _selector = (value: Q) => value as unknown as T; + } + + let _initialValue: T | undefined; + if (selector && typeof selector !== 'function') { + _initialValue = selector; + } else if (initialValue !== undefined) { + _initialValue = initialValue; + } else { + _initialValue = undefined; + } + + const [state, setState] = useState(_initialValue); + + useEffect(() => { + if (query.value !== undefined) { + const value = _selector(query.value); + if (!equals(value, state)) { + setState(value); + } + } + }, [query.value]); + + return [state, setState]; +}; + +/** + * React hook that tracks whether a message should be displayed based on a query or effect request. + * The message automatically expires after a specified timeout. When a new request is provided, + * any existing timer is cleared and a new countdown begins from the start. + * Useful for displaying temporary success or error messages that should disappear after a few seconds. + * + * @param request - The query or effect request object to track. When this changes to a new request, the expiration timer resets and starts counting down again. Can be `query.lastCompletedRequest` or `effect.lastCompletedRequest`. + * @param timeout - The duration in milliseconds before the message expires. The timer starts immediately when a request is provided. + * @returns A boolean indicating whether the message has expired. Returns `false` initially and whenever a new request is provided, `true` after the timeout expires. + * + * @example + * ```tsx + * // With an effect request + * const { lastCompletedRequest, isLoading } = useDogStore(s => ({ + * lastCompletedRequest: s.increasePopulation.lastCompletedRequest, + * isLoading: s.increasePopulation.isLoading + * })); + * + * const messageExpired = useMessageExpired(lastCompletedRequest, 3000); + * const showMessage = lastCompletedRequest && !isLoading && !messageExpired; + * + * return ( + * <> + * + * {showMessage && lastCompletedRequest.status === 'success' &&

Success!

} + * {showMessage && lastCompletedRequest.status === 'error' &&

Error!

} + * + * ); + * ``` + * + * @example + * ```tsx + * // With a query request + * const dogs = useDogStoreAsync(state => state.dogs); + * const messageExpired = useMessageExpired(dogs.lastCompletedRequest, 3000); + * + * if (dogs.isLoading) return ; + * if (dogs.error && !messageExpired) return ; + * return
{dogs.value}
; + * ``` + */ +export const useMessageExpired = (request: LeoRequest | undefined, timeout: number): boolean => { + const [messageExpired, setMessageExpired] = useState(false); + const timeoutRef = useRef(0); + useEffect(() => { + if (request) { + clearTimeout(timeoutRef.current); + if (messageExpired) { + setMessageExpired(false); + } + const timeoutId = setTimeout(() => setMessageExpired(true), timeout) as unknown as number; + timeoutRef.current = timeoutId; + } + return () => { + clearTimeout(timeoutRef.current); + } + }, [request, timeout]); + return messageExpired; +}; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index f6a9e99..7d9e535 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,4 +17,8 @@ export * from "./types"; export { events, createEvents -} from "./events"; \ No newline at end of file +} from "./events"; +export { + useQueryState, + useMessageExpired +} from "./forms"; \ No newline at end of file diff --git a/src/requests.ts b/src/requests.ts new file mode 100644 index 0000000..0ba39a0 --- /dev/null +++ b/src/requests.ts @@ -0,0 +1,56 @@ +import { EffectRequest, QueryRequest, Timing, GlobalOptions, LeoRequestStatus } from "./types"; +import { generateUUID } from "./util"; + +export const completeTiming = (timing: Timing) => { + return { + ...timing, + endTime: Date.now(), + duration: Date.now() - timing.startTime + }; +}; + +export const replaceRequest = (requests: EffectRequest[], request: EffectRequest) => { + return requests.map(r => r.id === request.id ? request : r); +}; + +export const startQueryRequest = (startTime: number, globalOptions: GlobalOptions) => { + const request: QueryRequest = { + id: generateUUID(globalOptions), + type: "query", + timing: {startTime}, + status: 'pending' as LeoRequestStatus + }; + return request; +}; + +export const completeQueryRequest = (request: QueryRequest, value?: T, error?: any): QueryRequest => { + const response = value ? {result: value} : {error: error!}; + return { + ...request, + timing: completeTiming(request.timing), + response, + status: 'success' as LeoRequestStatus + }; +}; + +export const startEffectRequest = (args: Args, globalOptions: GlobalOptions) => { + const startTime = Date.now(); + const request: EffectRequest = { + id: generateUUID(globalOptions), + type: "effect", + details: {args}, + timing: {startTime}, + status: 'pending' as LeoRequestStatus + }; + return request; +}; + +export const completeEffectRequest = (request: EffectRequest, result?: R, error?: any): EffectRequest => { + const response = result ? {result} : {error: error!}; + return { + ...request, + timing: completeTiming(request.timing), + response, + status: 'success' as LeoRequestStatus + }; +}; \ No newline at end of file diff --git a/src/src.ts b/src/src.ts index f2dc722..40db165 100644 --- a/src/src.ts +++ b/src/src.ts @@ -10,15 +10,15 @@ import { UseBoundAsyncStoreWithoutSuspense, UseBoundAsyncStoreWithSuspense, UseBoundAsyncStoreOptions, - SuccessPayload, - ErrorPayload, - SettledPayload, + RequestPayload, + QueryRequest, } from "./types"; import {wait, generateUUID} from "./util"; import {setupRetries} from "./retry"; import {useShallow} from "zustand/react/shallow"; import {events} from "./events"; import {useEffect} from "react"; +import { startEffectRequest, completeEffectRequest, replaceRequest, startQueryRequest, completeQueryRequest } from "./requests"; const mountedQueriesTracker = {} as Record>; @@ -40,8 +40,8 @@ const isQueryMounted = (queryId: string) => mountedQueriesTracker[queryId].size > 0; -export const isEffect = (v: any): v is Effect => - v && (v as Effect).__type === "Effect"; +export const isEffect = (v: any): v is Effect => + v && (v as Effect).__type === "Effect"; export const isQuery = (v: any): v is Query => v && (v as Query).__type === "Query" @@ -105,13 +105,13 @@ export const equals = (a: any, b: any): boolean => { return aValue === bValue; }; -interface EffectParams { - fn: (...args: Args) => Promise; +interface EffectParams { + fn: (...args: Args) => Promise; } -const effectParams = (args: any): EffectParams => { +const effectParams = (args: any): EffectParams => { const p = { - fn: args[0] as (...args: Args) => Promise, + fn: args[0] as (...args: Args) => Promise, }; if (!p.fn) { throw new Error("Invalid arguments"); @@ -124,42 +124,48 @@ const effectParams = (args: any): EffectParams => * simple promise. The promise will be executed when a dependency changes or is manually triggered. * @param fn - Function that executes the promise. The function name must match the key of the object tied to the store. */ -export function effect(fn: (...args: Args) => Promise): Effect; -export function effect(): Effect { - const p = effectParams(arguments); +export function effect(fn: (...args: Args) => Promise): Effect; +export function effect(): Effect { + const p = effectParams(arguments); const getStore: () => StoreApi = () => { throw new Error("Store not set yet"); }; - const e = { + + const e: Effect = { __id: generateUUID(globalOptions), __type: "Effect" as "Effect", __valueCounter: 0, __triggers: [], __store: getStore, key: "NOT_SET_YET" as keyof Store, + isIdle: true, isLoading: false, - error: undefined, - errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: async (...args: Args) => { - const current = e.__store().getState()[e.key] as Effect; + const current = e.__store().getState()[e.key] as Effect; const promise = p.fn(...args); + const request = startEffectRequest(args, globalOptions); e.__store().setState({ [e.key]: { ...current, __triggers: [...current.__triggers, promise], - isLoading: true + isLoading: true, + isIdle: false, + lastStartedRequest: request, + requests: [...current.requests, request] } } as Partial); setTimeout(async () => { - let error; + let result: R | undefined = undefined; + let error: any | undefined = undefined; try { - await promise; - events.__dispatchEvent(new CustomEvent("success", {detail: {effect: e}})); + result = await promise; } catch (ee) { error = ee; - events.__dispatchEvent(new CustomEvent("error", {detail: {effect: e, error}})); - } finally { - events.__dispatchEvent(new CustomEvent("settled", {detail: {effect: e, error}})); } - const current = e.__store().getState()[e.key] as Effect; + const completedRequest = completeEffectRequest(request, result, error); + events.__dispatchEvent(new CustomEvent(completedRequest.status, {detail: {effect: e, request: completedRequest}})); + events.__dispatchEvent(new CustomEvent("settled", {detail: {effect: e, request: completedRequest}})); const fetches = current.__triggers.filter(f => f !== promise); e.__store().setState({ [e.key]: { @@ -167,15 +173,15 @@ export function effect(): Effect< __valueCounter: current.__valueCounter + 1, __triggers: fetches, isLoading: fetches.length > 0, - error, - errors: [...current.errors, error], + lastCompletedRequest: completedRequest, + requests: replaceRequest(current.requests, completedRequest) } } as Partial); }); return promise; } }; - return e; + return e as Effect; } interface QueryParams { @@ -208,7 +214,7 @@ const setupStaleTimeout = (query: Query): num return undefined; }; -export const setSync = (query: Query, value?: R, error?: any, options: SetValueOptions = {}): Query => { +const setSyncWithRequest = (query: Query, value?: R, error?: any, options: SetValueOptions = {}, request: QueryRequest | undefined = undefined): Query => { if (!query.isLoading && !query.error && equals(query.value, value)) { return query; } @@ -230,7 +236,8 @@ export const setSync = (query: Query, value?: __valueTimestamp: Date.now(), isLoading: false, value: error === undefined ? value : undefined, - error + error, + lastCompletedRequest: request, }; if (options.__updateStore || options.__updateStore === undefined) { @@ -242,6 +249,10 @@ export const setSync = (query: Query, value?: return next; }; + +export const setSync = (query: Query, value?: R, error?: any, options: SetValueOptions = {}): Query => + setSyncWithRequest(query, value, error, options, undefined); + /** * Hook up an asynchronous query to Zustand. A query can be an HTTP request or simple promise. The promise will be * executed when a dependency changes or is manually triggered. @@ -275,11 +286,13 @@ export function query(): Query { isLoading: false, value: p.options.initialValue as unknown as R, error: undefined, + lastCompletedRequest: undefined, + lastStartedRequest: undefined, trigger: async (): Promise => { const state = q.__store().getState(); const current = state[q.key] as Query; - const now = Date.now(); - if (current.__trigger && current.__triggerStart > now - current.__debounce) { + const startTime = Date.now(); + if (current.__trigger && current.__triggerStart > startTime - current.__debounce) { return current.__trigger; } const queryDependencies = current.__deps(state).flatMap(v => { @@ -287,35 +300,36 @@ export function query(): Query { }); const initialPromise = Promise.all(queryDependencies).then(p.fn); const promise = setupRetries(p.fn, initialPromise, q); + const request = startQueryRequest(startTime, globalOptions); q.__store().setState({ [q.key]: { ...current, __trigger: promise, - __triggerStart: now, + __triggerStart: startTime, __needsLoad: false, __initialPromise: initialPromise, __isInitialized: true, - isLoading: true + isLoading: true, + lastStartedRequest: request } } as Partial); setTimeout(async () => { - let value = undefined; - let error = undefined; + let value: R | undefined = undefined; + let error: any | undefined = undefined; try { value = await promise; - events.__dispatchEvent(new CustomEvent("success", {detail: {query: q}})); } catch (e) { error = e; - events.__dispatchEvent(new CustomEvent("error", {detail: {query: q, error}})); - } finally { - events.__dispatchEvent(new CustomEvent("settled", {detail: {query: q, error}})); } + const completedRequest = completeQueryRequest(request, value, error); + events.__dispatchEvent(new CustomEvent(request.status, {detail: {query: q, request: completedRequest}})); + events.__dispatchEvent(new CustomEvent("settled", {detail: {query: q, request: completedRequest}})); const next = q.__store().getState()[q.key] as Query; if (next.__trigger !== promise) { return; } - setSync(next, value, error); + setSyncWithRequest(next, value, error, {}, completedRequest); }); return promise; }, @@ -341,9 +355,9 @@ export function query(): Query { return q; } -type QueryOrEffect = Query | Effect +type QueryOrEffect = Query | Effect -const select = (selector: (s: T) => QueryOrEffect) => (state: T): ([Query, ...QueryOrEffect[]] | [Effect, ...QueryOrEffect[]]) => { +const select = (selector: (s: T) => QueryOrEffect) => (state: T): ([Query, ...QueryOrEffect[]] | [Effect, ...QueryOrEffect[]]) => { const value = selector(state); if (isQuery(value)) { const query = value as unknown as Query; @@ -352,11 +366,11 @@ const select = (selector: (s: T) => QueryOrEffect) const state = query.__store().getState(); deps = query.__deps(state).flatMap(v => { return isQuery(v) || isEffect(v) ? [v] : []; - }) as (Query | Effect)[]; + }) as (Query | Effect)[]; } return [query, ...deps]; } else if (isEffect(value)) { - const effect = value as unknown as Effect; + const effect = value as Effect; return [effect]; } else { throw new Error("Must return Query or Effect"); @@ -365,7 +379,7 @@ const select = (selector: (s: T) => QueryOrEffect) function useTrackMountedQueries(selectors: { [K in keyof R]: ((state: T) => Query) | {selector: ((state: T) => Query), opts: UseBoundAsyncStoreOptions} }, store: UseBoundStore>): void; function useTrackMountedQueries(selector: (state: T) => Query, store: UseBoundStore>): void; -function useTrackMountedQueries(selector: (state: T) => Effect, store: UseBoundStore>): void; +function useTrackMountedQueries(selector: (state: T) => Effect, store: UseBoundStore>): void; function useTrackMountedQueries(selector: any, store: UseBoundStore>): void { useEffect(() => { const hookId = generateUUID(globalOptions); @@ -393,22 +407,25 @@ const withSuspenseHook = (store: UseBoundStore>): subscribe(store); function useBoundAsyncStore(selectors: { [K in keyof R]: ((state: T) => Query) | {selector: ((state: T) => Query), opts: UseBoundAsyncStoreOptions} }): R; function useBoundAsyncStore(selector: (state: T) => Query, opts?: UseBoundAsyncStoreOptions): R; - function useBoundAsyncStore(selector: (state: T) => Effect): (() => Promise); - function useBoundAsyncStore(s: any, o?: any): any { + function useBoundAsyncStore(selector: (state: T) => Effect): (() => Promise); + function useBoundAsyncStore(s: any, o?: any): any { useTrackMountedQueries(s, store); - function single(selector: (state: T) => Query | Effect, opts?: UseBoundAsyncStoreOptions): R | (() => Promise) { + function single(selector: (state: T) => Query | Effect, opts?: UseBoundAsyncStoreOptions): R | (() => Promise) { const _opts = opts || {}; - const theSelector = select(selector); + const theSelector = select(selector); const value = store(useShallow(theSelector)); - if (isQuery(value[0])) { - const v = withSuspense(value as [Query, ...QueryOrEffect[]], _opts as UseBoundAsyncStoreOptions); + const firstValue = value[0]; + const otherValues = value.slice(1) as QueryOrEffect[]; + if (isQuery(firstValue)) { + const v = withSuspense(firstValue, otherValues, _opts as UseBoundAsyncStoreOptions); if (v.error) { throw v.error; } else { return v.value!; } - } else if (isEffect(value[0])) { - return withSuspense(value as [Effect, ...QueryOrEffect[]]); + } else if (isEffect(firstValue)) { + const v = firstValue as Effect; + return withSuspense(v, otherValues); } else { throw new Error("Value must be Query or Effect"); } @@ -445,29 +462,32 @@ const withoutSuspenseHook = (store: UseBoundStore> subscribe(store); function useBoundAsyncStoreWithoutSuspense(selectors: {[K in keyof R]: ((state: T) => Query) | {selector: ((state: T) => Query), opts: UseBoundAsyncStoreOptions}}): {[K in keyof R]: QueryValue}; function useBoundAsyncStoreWithoutSuspense(selector: (state: T) => Query, opts?: UseBoundAsyncStoreOptions): QueryValue; - function useBoundAsyncStoreWithoutSuspense(selector: (state: T) => Effect): (() => Promise); + function useBoundAsyncStoreWithoutSuspense(selector: (state: T) => Effect): (() => Promise); function useBoundAsyncStoreWithoutSuspense(s: any, o?: any): any { useTrackMountedQueries(s, store); - function single(selector: (state: T) => Query | Effect, opts?: UseBoundAsyncStoreOptions): QueryValue | (() => Promise) { + function single(selector: (state: T) => Query | Effect, opts?: UseBoundAsyncStoreOptions): QueryValue | (() => Promise) { const _opts = opts || {}; - const theSelector = select(selector); + const theSelector = select(selector); const value = store(useShallow(theSelector)); - if (isQuery(value[0])) { - const v = value[0] as Query; + const firstValue = value[0]; + const otherValues = value.slice(1); + if (isQuery(firstValue)) { try { - return withSuspense(value as [Query, ...QueryOrEffect[]], _opts); + return withSuspense(firstValue, otherValues, _opts); } catch (e) { return { - value: v.value, + value: firstValue.value, isLoading: true, - error: v.error + error: firstValue.error, + lastCompletedRequest: firstValue.lastCompletedRequest } } - } else if (isEffect(value[0])) { + } else if (isEffect(firstValue)) { + const v = firstValue as unknown as Effect; try { - return withSuspense(value as [Effect, ...QueryOrEffect[]]); + return withSuspense(v, otherValues); } catch (e) { - return value[0].trigger; + return v.trigger; } } else { throw new Error("Value must be Query or Effect"); @@ -552,27 +572,26 @@ const assertOptions = (opts: UseBoundAsyncStoreOptions) => { } }; -const withSuspenseQuery = (values: [Query, ...QueryOrEffect[]], opts: UseBoundAsyncStoreOptions): QueryValue => { +const withSuspenseQuery = (firstValue: Query, otherValues: QueryOrEffect[], opts: UseBoundAsyncStoreOptions): QueryValue => { assertOptions(opts); - const v = values[0] as Query; - const needsTrigger = v.__needsLoad || v.isLoading; - const _needsInitialValue = needsInitialValue(v, opts); - const _needsValue = needsValue(v, opts); + const needsTrigger = firstValue.__needsLoad || firstValue.isLoading; + const _needsInitialValue = needsInitialValue(firstValue, opts); + const _needsValue = needsValue(firstValue, opts); let queryTrigger: undefined | Promise[] = undefined; let depTriggers: undefined | Promise[] = undefined; if (_needsInitialValue) { - wait().then(() => { setInitialValue(v, opts.initialValue!); }); + wait().then(() => { setInitialValue(firstValue, opts.initialValue!); }); queryTrigger = []; } else if (_needsValue) { - wait().then(() => { setValue(v, opts.value!, opts.timestamp!); }); + wait().then(() => { setValue(firstValue, opts.value!, opts.timestamp!); }); queryTrigger = []; } else if (needsTrigger && opts.hydration) { queryTrigger = [opts.hydration as Promise]; - } else if (needsTrigger && v.__trigger) { - queryTrigger = [v.__trigger]; + } else if (needsTrigger && firstValue.__trigger) { + queryTrigger = [firstValue.__trigger]; } else if (needsTrigger) { - queryTrigger = [wait().then(v.trigger)]; + queryTrigger = [wait().then(firstValue.trigger)]; } else { queryTrigger = []; } @@ -584,7 +603,7 @@ const withSuspenseQuery = (values: [Query, ...QueryOrEffect[]], } else if (opts.hydration) { depTriggers = []; } else { - depTriggers = values.slice(1).flatMap(vv => { + depTriggers = otherValues.flatMap(vv => { if (isQuery(vv)) { return vv.__trigger ? [vv.__trigger] : []; } else if (isEffect(vv)) { @@ -596,20 +615,19 @@ const withSuspenseQuery = (values: [Query, ...QueryOrEffect[]], } const allTriggers = [...queryTrigger, ...depTriggers]; if (_needsInitialValue) { - return {value: opts.initialValue!, error: undefined, isLoading: false}; + return {value: opts.initialValue!, error: undefined, isLoading: false, lastCompletedRequest: undefined}; } else if (_needsValue) { - return {value: opts.value!, error: undefined, isLoading: false}; + return {value: opts.value!, error: undefined, isLoading: false, lastCompletedRequest: undefined}; } else if (allTriggers.length === 0) { - return {value: v.value, error: v.error, isLoading: false}; + return {value: firstValue.value, error: firstValue.error, isLoading: false, lastCompletedRequest: firstValue.lastCompletedRequest}; } else { throw Promise.all(allTriggers); } }; -const withSuspenseEffect = (values: [Effect, ...QueryOrEffect[]]): (() => Promise) => { - const v = values[0]; - const effectTriggers = v.__triggers; - const depTriggers = values.slice(1).flatMap(vv => { +const withSuspenseEffect = (firstValue: Effect, otherValues: QueryOrEffect[]): (() => Promise) => { + const effectTriggers = firstValue.__triggers; + const depTriggers = otherValues.flatMap(vv => { if (isQuery(vv)) { return vv.__trigger ? [vv.__trigger] : []; } else if (isEffect(vv)) { @@ -620,7 +638,7 @@ const withSuspenseEffect = (values: [Effect, ...Q }); const allTriggers = [...effectTriggers, ...depTriggers]; if (allTriggers.length === 0) { - return v.trigger; + return firstValue.trigger; } else { throw Promise.all(allTriggers); } @@ -631,17 +649,17 @@ const withSuspenseEffect = (values: [Effect, ...Q * query is loading. * @param query */ -function withSuspense(query: [Query, ...QueryOrEffect[]], opts: UseBoundAsyncStoreOptions): QueryValue; +function withSuspense(firstValue: Query, otherValues: QueryOrEffect[], opts: UseBoundAsyncStoreOptions): QueryValue; /** * Hook up your effect(s) so it will trigger when the effect is loading. * @param effect */ -function withSuspense(effect: [Effect, ...QueryOrEffect[]]): (() => Promise); -function withSuspense(values: [Query, ...QueryOrEffect[]] | [Effect, ...QueryOrEffect[]], opts?: UseBoundAsyncStoreOptions): QueryValue | ((...args: Args) => Promise) { - if (isQuery(values[0])) { - return withSuspenseQuery(values as [Query, ...QueryOrEffect[]], opts!); - } else if (isEffect(values[0])) { - return withSuspenseEffect(values as [Effect, ...QueryOrEffect[]]); +function withSuspense(firstValue: Effect, otherValues: QueryOrEffect[]): (() => Promise); +function withSuspense(firstValue: Query | Effect, otherValues: QueryOrEffect[], opts?: UseBoundAsyncStoreOptions ): QueryValue | ((...args: Args) => Promise) { + if (isQuery(firstValue)) { + return withSuspenseQuery(firstValue, otherValues, opts!); + } else if (isEffect(firstValue)) { + return withSuspenseEffect(firstValue, otherValues); } else { throw new Error("Value must be Query or Effect"); } diff --git a/src/types.ts b/src/types.ts index 6981e3b..1680d53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,53 @@ import { ReactNode } from "react"; import {UseBoundStore, StoreApi} from "zustand"; +export type LeoRequestStatus = 'success' | 'error' | 'pending'; + +export interface Timing { + /** The timestamp when the request started. */ + startTime: number; + /** The timestamp when the request ended. */ + endTime?: number; + /** The duration of the request. */ + duration?: number; +} + +export interface EffectRequestDetails { + /** The arguments passed to the effect. */ + args: Args; +} + +export interface LeoResponse { + /** The result of the effect. */ + result?: T; + /** The error of the effect. */ + error?: any | undefined; +} + +export interface LeoRequest { + /** The index of the request. */ + id: string; + /** The response of the request. */ + response?: LeoResponse; + /** Timing information for the request. */ + timing: Timing; + /** The status of the request. */ + status: LeoRequestStatus; +} + +export interface EffectRequest extends LeoRequest { + type: "effect"; + details: EffectRequestDetails; +} + +export interface QueryRequest extends LeoRequest { + type: "query"; +} + /** * Represents an asynchronous effect tied to a Zustand store. */ -export interface Effect { +export interface Effect { /** Unique identifier for this effect. */ readonly __id: string; /** Type identifier for this effect. */ @@ -12,19 +55,23 @@ export interface Effect { /** Tracks how many times the effect has been triggered. */ readonly __valueCounter: number; /** Array of promises for managing concurrent triggers. */ - readonly __triggers: Promise[]; + readonly __triggers: Promise[]; /** Function access to the store. */ __store: () => StoreApi; /** Key of the store this effect is tied to. */ key: keyof State; /** Indicates if the effect is currently executing. */ isLoading: boolean; - /** Error from the most recent trigger attempt. Undefined if the most recent trigger succeeded or no trigger has been attempted. */ - error: any | undefined; - /** History of all errors hit by the effect. Useful for debugging. */ - readonly errors: any[]; + /** Indicates if the effect is currently idle. */ + isIdle: boolean; + /** The most recently started request of the effect (may still be pending). */ + lastStartedRequest: EffectRequest | undefined; + /** The most recently completed request of the effect. */ + lastCompletedRequest: EffectRequest | undefined; + /** History of all requests made by the effect. Useful for debugging. */ + readonly requests: EffectRequest[]; /** Triggers the effect manually. */ - trigger: (...args: Args) => Promise; + trigger: (...args: Args) => Promise; } /** @@ -94,6 +141,10 @@ export interface Query { * @returns The updated query. */ withValue: (value: T) => Query; + /** The most recently started request of the query (may still be pending). */ + lastStartedRequest: QueryRequest | undefined; + /** The most recently completed request of the query. */ + lastCompletedRequest: QueryRequest | undefined; } export type Primitive = string | number | boolean | null | undefined | bigint | symbol; @@ -105,6 +156,8 @@ export interface QueryValue { isLoading: boolean; /** Error caught in the promise. */ error: any | undefined; + /** The most recently completed request of the query. */ + lastCompletedRequest: QueryRequest | undefined; } @@ -114,7 +167,7 @@ export interface QueryValue { * @param s - The current store state. * @returns An array of `Query | Effect | Primitive`. */ -export type Dependencies = (s: Store) => (Query | Effect | Primitive)[]; +export type Dependencies = (s: Store) => (Query | Effect | Primitive)[]; export interface GlobalQueryOptions { /** If set to `true`, the query will fetch data as needed. Default is `true`. */ @@ -217,7 +270,7 @@ export type UseBoundAsyncStoreWithSuspense = { * @param selector Select the effect from the store. * @param opts Options */ - (selector: (state: T) => Effect): () => Promise + (selector: (state: T) => Effect): () => Promise }; export type UseBoundAsyncStoreWithoutSuspense = { @@ -247,7 +300,7 @@ export type UseBoundAsyncStoreWithoutSuspense = { * - Consider dependencies * @param selector Select the effect from the store. */ - (selector: (state: T) => Effect): () => Promise + (selector: (state: T) => Effect): () => Promise }; export interface StoreProviderProps { @@ -347,29 +400,18 @@ export interface StoreProviderWithServerSideData { readonly useHasHydrated: () => boolean; } -export interface SuccessPayload { - query?: Query; - effect?: Effect; -} - -export interface ErrorPayload { - query?: Query; - effect?: Effect; - error: any; -} - -export interface SettledPayload { +export interface RequestPayload { query?: Query; - effect?: Effect; - error?: any; + effect?: Effect; + request: EffectRequest | QueryRequest; } export interface LeoQueryEventTarget { - addEventListener(type: "success", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - addEventListener(type: "error", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - addEventListener(type: "settled", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - removeEventListener(type: "success", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - removeEventListener(type: "error", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - removeEventListener(type: "settled", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; - __dispatchEvent(evt: CustomEvent): void; + addEventListener(type: "success", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + addEventListener(type: "error", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + addEventListener(type: "settled", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + removeEventListener(type: "success", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + removeEventListener(type: "error", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + removeEventListener(type: "settled", listener: (evt: CustomEvent) => void, options?: AddEventListenerOptions | boolean): void; + __dispatchEvent(evt: CustomEvent): void; } \ No newline at end of file diff --git a/test-apps/dogs-ts/package-lock.json b/test-apps/dogs-ts/package-lock.json index 5b44cd4..d372afb 100644 --- a/test-apps/dogs-ts/package-lock.json +++ b/test-apps/dogs-ts/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/test-apps/dogs-ts/src/App.css b/test-apps/dogs-ts/src/App.css index 989ff0b..b265a04 100644 --- a/test-apps/dogs-ts/src/App.css +++ b/test-apps/dogs-ts/src/App.css @@ -58,4 +58,12 @@ body { width: 150px; margin-bottom: 20px; animation: float 3s ease-in-out infinite; +} + +.response-message { + color: #264653; + font-size: 18px; + font-weight: 700; + margin-top: 20px; + animation: pop 0.3s ease-in-out; } \ No newline at end of file diff --git a/test-apps/dogs-ts/src/App.tsx b/test-apps/dogs-ts/src/App.tsx index df082ac..cef18ff 100644 --- a/test-apps/dogs-ts/src/App.tsx +++ b/test-apps/dogs-ts/src/App.tsx @@ -1,6 +1,6 @@ -import React, {Suspense} from 'react'; import {create} from "zustand"; -import {hook, effect, query, Query, Effect} from "leo-query"; +import { useShallow } from "zustand/react/shallow"; +import {hook, effect, query, Query, Effect, useMessageExpired} from "leo-query"; import {fetchDogs, increasePopulation, removeAllDogs} from "./db"; import "./App.css"; @@ -21,10 +21,19 @@ const useDogStoreAsync = hook(useDogStore, /*suspense*/false); function DogCounter() { console.log("Rendering DogCounter"); const dogs = useDogStoreAsync(state => state.dogs); + const messageExpired = useMessageExpired(dogs.lastCompletedRequest, /*timeout*/3000); if (dogs.isLoading) { return ; } - return

{dogs.value} around here...

; + if (dogs.error && !messageExpired) { + return

Error: {dogs.error.message}

; + } + return ( + <> +

{dogs.value} around here...

+ {!messageExpired &&

Success!

} + + ); } function Loading () { @@ -32,9 +41,15 @@ function Loading () { } function Controls() { - console.log("Rendering Controls"); - const increasePopulation = useDogStore(state => state.increasePopulation.trigger) - return ; + const [increasePopulation, isLoading, lastCompletedRequest] = useDogStore(useShallow(s => [s.increasePopulation.trigger, s.increasePopulation.isLoading,s.increasePopulation.lastCompletedRequest])); + const messageExpired = useMessageExpired(lastCompletedRequest, /*timeout*/30000); + console.log("Rendering Controls", messageExpired); + + return ( + <> + + + ); } function App() { diff --git a/test-apps/dogs-ts/src/db.ts b/test-apps/dogs-ts/src/db.ts index 6aefd8f..913522c 100644 --- a/test-apps/dogs-ts/src/db.ts +++ b/test-apps/dogs-ts/src/db.ts @@ -6,7 +6,7 @@ export const fetchDogs = (): Promise => { return new Promise((resolve) => { setTimeout(() => { resolve(db.dogs); - }, 5000); + }, 1000); }); }; @@ -15,7 +15,7 @@ export const increasePopulation = (): Promise => { setTimeout(() => { db.dogs = db.dogs + 1; resolve(); - }, 5000); + }, 1000); }); }; @@ -24,6 +24,6 @@ export const removeAllDogs = (): Promise => { setTimeout(() => { db.dogs = db.dogs = 0; resolve(); - }, 500); + }, 1000); }); }; \ No newline at end of file diff --git a/test-apps/nextjs-dashboard/package-lock.json b/test-apps/nextjs-dashboard/package-lock.json index e689aad..b597742 100644 --- a/test-apps/nextjs-dashboard/package-lock.json +++ b/test-apps/nextjs-dashboard/package-lock.json @@ -31,7 +31,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/test-apps/nextjs-persist/package-lock.json b/test-apps/nextjs-persist/package-lock.json index 3f34464..e7e5fbf 100644 --- a/test-apps/nextjs-persist/package-lock.json +++ b/test-apps/nextjs-persist/package-lock.json @@ -31,7 +31,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/test-apps/parallel-queries/package-lock.json b/test-apps/parallel-queries/package-lock.json index dacadf3..61429de 100644 --- a/test-apps/parallel-queries/package-lock.json +++ b/test-apps/parallel-queries/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/test-apps/persist/package-lock.json b/test-apps/persist/package-lock.json index 9a37e26..daa4e71 100644 --- a/test-apps/persist/package-lock.json +++ b/test-apps/persist/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/test-apps/todos/package-lock.json b/test-apps/todos/package-lock.json index bb29641..0a510b8 100644 --- a/test-apps/todos/package-lock.json +++ b/test-apps/todos/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.5.0", + "version": "0.6.0-rc.0", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/tests/src.test.ts b/tests/src.test.ts index 90e040a..ede9319 100644 --- a/tests/src.test.ts +++ b/tests/src.test.ts @@ -20,8 +20,12 @@ test("equal effects", () => { __triggers: [], key: "key1", isLoading: false, + isIdle: true, error: undefined, errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: () => Promise.resolve(), __store: () => { return null as unknown as StoreApi; } } as Effect; @@ -34,8 +38,12 @@ test("equal effects", () => { __triggers: [], key: "key2", isLoading: false, + isIdle: true, error: undefined, errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: () => Promise.resolve(), __store: () => { return null as unknown as StoreApi; } } as Effect @@ -52,8 +60,12 @@ test("not equal effects", () => { __triggers: [], key: "key1", isLoading: false, + isIdle: true, error: undefined, errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: () => Promise.resolve(), __store: () => { return null as unknown as StoreApi; } } as Effect; @@ -66,8 +78,12 @@ test("not equal effects", () => { __triggers: [], key: "key2", isLoading: false, + isIdle: true, error: undefined, errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: () => Promise.resolve(), __store: () => { return null as unknown as StoreApi; } } as Effect @@ -84,8 +100,12 @@ test("effect compared with non-effect", () => { __triggers: [], key: "key1", isLoading: false, + isIdle: true, error: undefined, errors: [], + lastStartedRequest: undefined, + lastCompletedRequest: undefined, + requests: [], trigger: () => Promise.resolve(), __store: () => { return null as unknown as StoreApi; } } as Effect; From 96e9ff2899425b4fcd3b69ca387b09d4ebf63f73 Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Sat, 27 Dec 2025 11:17:35 -0700 Subject: [PATCH 2/6] Fix request status --- package-lock.json | 4 ++-- package.json | 2 +- src/requests.ts | 10 ++++++---- test-apps/dogs-ts/package-lock.json | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b47044..17693b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "leo-query", - "version": "0.6.0-rc.0", + "version": "0.6.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "leo-query", - "version": "0.6.0-rc.0", + "version": "0.6.0-rc.1", "license": "MIT", "dependencies": { "typescript": "^4.7.3" diff --git a/package.json b/package.json index ed32810..3aa6fc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "leo-query", - "version": "0.6.0-rc.0", + "version": "0.6.0-rc.1", "description": "A simple library to connect async queries to Zustand stores.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/requests.ts b/src/requests.ts index 0ba39a0..036ac81 100644 --- a/src/requests.ts +++ b/src/requests.ts @@ -24,12 +24,13 @@ export const startQueryRequest = (startTime: number, globalOptions: GlobalOpt }; export const completeQueryRequest = (request: QueryRequest, value?: T, error?: any): QueryRequest => { - const response = value ? {result: value} : {error: error!}; + const response = value !== undefined ? {result: value} : {error: error!}; + const status = value !== undefined ? 'success' as LeoRequestStatus : 'error' as LeoRequestStatus; return { ...request, timing: completeTiming(request.timing), response, - status: 'success' as LeoRequestStatus + status }; }; @@ -46,11 +47,12 @@ export const startEffectRequest = (args: Args, globalOpti }; export const completeEffectRequest = (request: EffectRequest, result?: R, error?: any): EffectRequest => { - const response = result ? {result} : {error: error!}; + const response = result !== undefined ? {result} : {error: error!}; + const status = result !== undefined ? 'success' as LeoRequestStatus : 'error' as LeoRequestStatus; return { ...request, timing: completeTiming(request.timing), response, - status: 'success' as LeoRequestStatus + status }; }; \ No newline at end of file diff --git a/test-apps/dogs-ts/package-lock.json b/test-apps/dogs-ts/package-lock.json index d372afb..6c410d1 100644 --- a/test-apps/dogs-ts/package-lock.json +++ b/test-apps/dogs-ts/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.6.0-rc.0", + "version": "0.6.0-rc.1", "license": "MIT", "dependencies": { "typescript": "^4.7.3" From 438a234e83933cb53bd379eae3b8eaf7eeac1972 Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Sun, 28 Dec 2025 02:56:46 -0700 Subject: [PATCH 3/6] More status fixes --- src/requests.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/requests.ts b/src/requests.ts index 036ac81..0a4496e 100644 --- a/src/requests.ts +++ b/src/requests.ts @@ -23,9 +23,9 @@ export const startQueryRequest = (startTime: number, globalOptions: GlobalOpt return request; }; -export const completeQueryRequest = (request: QueryRequest, value?: T, error?: any): QueryRequest => { - const response = value !== undefined ? {result: value} : {error: error!}; - const status = value !== undefined ? 'success' as LeoRequestStatus : 'error' as LeoRequestStatus; +export const completeQueryRequest = (request: QueryRequest, result?: T, error?: any): QueryRequest => { + const response = error !== undefined ? {error} : {result}; + const status = error !== undefined ? 'error' as LeoRequestStatus : 'success' as LeoRequestStatus; return { ...request, timing: completeTiming(request.timing), @@ -47,8 +47,8 @@ export const startEffectRequest = (args: Args, globalOpti }; export const completeEffectRequest = (request: EffectRequest, result?: R, error?: any): EffectRequest => { - const response = result !== undefined ? {result} : {error: error!}; - const status = result !== undefined ? 'success' as LeoRequestStatus : 'error' as LeoRequestStatus; + const response = error !== undefined ? {error} : {result}; + const status = error !== undefined ? 'error' as LeoRequestStatus : 'success' as LeoRequestStatus; return { ...request, timing: completeTiming(request.timing), From 17175f422d7551d3974228c8d3665bc3aceb0afd Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Sun, 28 Dec 2025 11:26:52 -0700 Subject: [PATCH 4/6] Add isSucces and isError --- package.json | 2 +- src/src.ts | 56 ++++++++++++++++++++++++----- src/types.ts | 22 +++++++++--- test-apps/dogs-ts/package-lock.json | 2 +- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 3aa6fc6..a451388 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "leo-query", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "description": "A simple library to connect async queries to Zustand stores.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/src.ts b/src/src.ts index 40db165..9ee026d 100644 --- a/src/src.ts +++ b/src/src.ts @@ -136,8 +136,11 @@ export function effect(): Effect(): Effect); setTimeout(async () => { - let result: R | undefined = undefined; + let value: R | undefined = undefined; let error: any | undefined = undefined; try { - result = await promise; + value = await promise; } catch (ee) { error = ee; } - const completedRequest = completeEffectRequest(request, result, error); + const current = e.__store().getState()[e.key] as Effect; + const completedRequest = completeEffectRequest(request, value, error); events.__dispatchEvent(new CustomEvent(completedRequest.status, {detail: {effect: e, request: completedRequest}})); events.__dispatchEvent(new CustomEvent("settled", {detail: {effect: e, request: completedRequest}})); const fetches = current.__triggers.filter(f => f !== promise); + const isSuccess = completedRequest.status === "success"; + const isError = completedRequest.status === "error"; e.__store().setState({ [e.key]: { ...current, @@ -174,6 +179,10 @@ export function effect(): Effect 0, lastCompletedRequest: completedRequest, + isSuccess, + isError, + value, + error, requests: replaceRequest(current.requests, completedRequest) } } as Partial); @@ -225,6 +234,8 @@ const setSyncWithRequest = (query: Query, val return query; } const staleTimeout = setupStaleTimeout(query); + const isSuccess = error === undefined; + const isError = error !== undefined; const next = { ...query, __isInitialized: true, @@ -235,7 +246,9 @@ const setSyncWithRequest = (query: Query, val __staleTimeout: staleTimeout, __valueTimestamp: Date.now(), isLoading: false, - value: error === undefined ? value : undefined, + isSuccess, + isError, + value: value, error, lastCompletedRequest: request, }; @@ -284,6 +297,8 @@ export function query(): Query { __isInitialized: p.options.initialValue !== undefined, key: "NOT_YET_SET" as keyof Store, isLoading: false, + isSuccess: undefined, + isError: undefined, value: p.options.initialValue as unknown as R, error: undefined, lastCompletedRequest: undefined, @@ -478,6 +493,8 @@ const withoutSuspenseHook = (store: UseBoundStore> return { value: firstValue.value, isLoading: true, + isSuccess: firstValue.isSuccess, + isError: firstValue.isError, error: firstValue.error, lastCompletedRequest: firstValue.lastCompletedRequest } @@ -615,11 +632,32 @@ const withSuspenseQuery = (firstValue: Query, otherValues: QueryOrEff } const allTriggers = [...queryTrigger, ...depTriggers]; if (_needsInitialValue) { - return {value: opts.initialValue!, error: undefined, isLoading: false, lastCompletedRequest: undefined}; + return { + value: opts.initialValue!, + error: undefined, + isLoading: false, + isSuccess: undefined, + isError: undefined, + lastCompletedRequest: undefined + }; } else if (_needsValue) { - return {value: opts.value!, error: undefined, isLoading: false, lastCompletedRequest: undefined}; + return { + value: opts.value!, + error: undefined, + isLoading: false, + isSuccess: true, + isError: false, + lastCompletedRequest: undefined + }; } else if (allTriggers.length === 0) { - return {value: firstValue.value, error: firstValue.error, isLoading: false, lastCompletedRequest: firstValue.lastCompletedRequest}; + return { + value: firstValue.value, + error: firstValue.error, + isLoading: false, + isSuccess: firstValue.isSuccess, + isError: firstValue.isError, + lastCompletedRequest: firstValue.lastCompletedRequest + }; } else { throw Promise.all(allTriggers); } diff --git a/src/types.ts b/src/types.ts index 1680d53..770e047 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,8 +62,14 @@ export interface Effect { key: keyof State; /** Indicates if the effect is currently executing. */ isLoading: boolean; - /** Indicates if the effect is currently idle. */ - isIdle: boolean; + /** Indicates if the most recent trigger attempt by the effect has succeeded. */ + isSuccess: boolean | undefined; + /** Indicates if the most recent trigger attempt by the effect has errored. */ + isError: boolean | undefined; + /** Value from the most recently completed trigger attempt. Undefined if the most recent trigger succeeded or no trigger has been attempted. */ + value: R | undefined; + /** Error from the most recently completed trigger attempt. Undefined if the most recent trigger succeeded or no trigger has been attempted. */ + error: any | undefined; /** The most recently started request of the effect (may still be pending). */ lastStartedRequest: EffectRequest | undefined; /** The most recently completed request of the effect. */ @@ -122,6 +128,10 @@ export interface Query { value: T | undefined; /** Indicates if the query is currently fetching data. */ isLoading: boolean; + /** Indicates if the most recent completed attempt by the query has succeeded. */ + isSuccess: boolean | undefined; + /** Indicates if the most recent completed attempt by the query has errored. */ + isError: boolean | undefined; /** Error caught in the promise. */ error: any | undefined; /** Manually triggers the query. */ @@ -152,10 +162,14 @@ export type Primitive = string | number | boolean | null | undefined | bigint | export interface QueryValue { /** The current value returned by the query. */ value: T | undefined; - /** Indicates if the query is currently fetching data. */ - isLoading: boolean; /** Error caught in the promise. */ error: any | undefined; + /** Indicates if the query is currently fetching data. */ + isLoading: boolean; + /** Indicates if the most recent completed attempt by the query has succeeded. */ + isSuccess: boolean | undefined; + /** Indicates if the most recent completed attempt by the query has errored. */ + isError: boolean | undefined; /** The most recently completed request of the query. */ lastCompletedRequest: QueryRequest | undefined; } diff --git a/test-apps/dogs-ts/package-lock.json b/test-apps/dogs-ts/package-lock.json index 6c410d1..da94045 100644 --- a/test-apps/dogs-ts/package-lock.json +++ b/test-apps/dogs-ts/package-lock.json @@ -28,7 +28,7 @@ } }, ".yalc/leo-query": { - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "license": "MIT", "dependencies": { "typescript": "^4.7.3" From cf60e02cb5ebafaaec5ed1396e60ab5dfc098f80 Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Sun, 28 Dec 2025 11:26:58 -0700 Subject: [PATCH 5/6] Update package-lock --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 17693b8..bc62d03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "leo-query", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "leo-query", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "license": "MIT", "dependencies": { "typescript": "^4.7.3" From ecccf0504e697364cdffb40cc3654f5306b154f1 Mon Sep 17 00:00:00 2001 From: Steven Wexler Date: Sun, 28 Dec 2025 11:29:27 -0700 Subject: [PATCH 6/6] Fix tests --- tests/src.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/src.test.ts b/tests/src.test.ts index ede9319..d1915da 100644 --- a/tests/src.test.ts +++ b/tests/src.test.ts @@ -20,7 +20,9 @@ test("equal effects", () => { __triggers: [], key: "key1", isLoading: false, - isIdle: true, + isSuccess: undefined, + isError: undefined, + value: undefined, error: undefined, errors: [], lastStartedRequest: undefined, @@ -38,7 +40,9 @@ test("equal effects", () => { __triggers: [], key: "key2", isLoading: false, - isIdle: true, + isSuccess: undefined, + isError: undefined, + value: undefined, error: undefined, errors: [], lastStartedRequest: undefined, @@ -60,7 +64,9 @@ test("not equal effects", () => { __triggers: [], key: "key1", isLoading: false, - isIdle: true, + isSuccess: undefined, + isError: undefined, + value: undefined, error: undefined, errors: [], lastStartedRequest: undefined, @@ -78,7 +84,9 @@ test("not equal effects", () => { __triggers: [], key: "key2", isLoading: false, - isIdle: true, + isSuccess: undefined, + isError: undefined, + value: undefined, error: undefined, errors: [], lastStartedRequest: undefined, @@ -100,7 +108,9 @@ test("effect compared with non-effect", () => { __triggers: [], key: "key1", isLoading: false, - isIdle: true, + isSuccess: undefined, + isError: undefined, + value: undefined, error: undefined, errors: [], lastStartedRequest: undefined,