diff --git a/package.json b/package.json index cc2c81b..89ca35f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@emoji-mart/react": "^1.1.1", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", + "@tanstack/react-query": "5", + "@tanstack/react-query-devtools": "5", "@tanstack/react-virtual": "^3.13.24", "dompurify": "^3.4.2", "emoji-mart": "^5.6.0", diff --git a/src/api/nitro-query/createNitroQuery.ts b/src/api/nitro-query/createNitroQuery.ts index 44ff82f..1bf73ff 100644 --- a/src/api/nitro-query/createNitroQuery.ts +++ b/src/api/nitro-query/createNitroQuery.ts @@ -1,42 +1,20 @@ -/** - * Adapter prototype for proposal #2 (server requests as queries). - * - * NOT YET ENABLED — `@tanstack/react-query` is not in package.json. - * To activate: - * - * yarn add @tanstack/react-query @tanstack/react-query-devtools - * - * Then mount the provider once in `src/index.tsx`: - * - * import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - * const queryClient = new QueryClient({ - * defaultOptions: { queries: { staleTime: 30_000, retry: 1 } } - * }); - * - * - * Then this file becomes: - * - * import { useQuery } from '@tanstack/react-query'; - * ... - * - * The interface below shows the intended API. Once enabled, replace the - * placeholder bodies with the real `useQuery` calls. - */ - -import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { GetCommunication, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { SendMessageComposer } from '../SendMessageComposer'; export interface NitroQueryConfig { /** - * Stable key used for caching/deduping (TanStack Query queryKey). - * Convention: ['nitro', '', '', ...args]. + * Stable key for caching/deduping. Convention: + * `['nitro', '', '', ...args]`. */ - key: readonly unknown[]; + key: QueryKey; /** * Factory for the request composer. Called once per query execution. + * `null` skips sending (useful when the server pushes the event + * unprompted — you only want subscription, not a request). */ - request: () => any; + request: (() => unknown) | null; /** * The parser class to listen for as the response. */ @@ -46,39 +24,95 @@ export interface NitroQueryConfig */ select?: (event: TParser) => TData; /** - * Optional max time to wait for the response before failing. + * Max time to wait for the response before rejecting (default 15s). */ timeoutMs?: number; + /** + * Forwarded to TanStack Query. + */ + enabled?: boolean; + staleTime?: number; + refetchOnMount?: boolean | 'always'; } /** - * Build a one-shot Promise that sends a composer and resolves with the - * matching parser event. To be passed into TanStack Query's queryFn: + * Wraps a Nitro composer/parser request-response pair as a TanStack Query + * `useQuery` call. The returned object is the standard TanStack result — + * `{ data, isLoading, isError, error, refetch, ... }`. * - * useQuery({ - * queryKey: cfg.key, - * queryFn: () => awaitNitroResponse(cfg), - * }); - * - * Implementation outline (filled in once react-query is added): - * - * return new Promise((resolve, reject) => { - * const event = new cfg.parser((e: TParser) => { - * GetCommunication().removeMessageEvent(event); - * resolve(cfg.select ? cfg.select(e) : (e as unknown as TData)); - * }); - * GetCommunication().registerMessageEvent(event); - * SendMessageComposer(cfg.request()); - * if (cfg.timeoutMs) setTimeout(() => { - * GetCommunication().removeMessageEvent(event); - * reject(new Error('NitroQuery timeout')); - * }, cfg.timeoutMs); - * }); + * Behavior: + * - On the first subscribe, registers the parser, sends the composer, + * resolves the Promise with the selected payload when the parser fires. + * - Default `staleTime` is the QueryClient default (30s). + * - Subsequent mounts within `staleTime` get the cached value immediately; + * the request is NOT re-sent. + * - Identical concurrent calls (same `key`) are deduped. + */ +export const useNitroQuery = ( + config: NitroQueryConfig +): UseQueryResult => +{ + const { key, request, parser, select, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config; + + const options: UseQueryOptions = { + queryKey: key, + queryFn: () => awaitNitroResponse({ key, request, parser, select, timeoutMs }), + enabled, + staleTime, + refetchOnMount + }; + + return useQuery(options); +}; + +/** + * Lower-level helper: send a composer (if any) and resolve with the next + * matching parser event. Exposed so `queryClient.fetchQuery({...})` callers + * can use the same plumbing imperatively. */ export const awaitNitroResponse = ( - _cfg: NitroQueryConfig + config: Pick, 'request' | 'parser' | 'select' | 'timeoutMs'> ): Promise => -{ - void SendMessageComposer; - throw new Error('useNitroQuery is not enabled. See docs/ARCHITECTURE.md proposal #2.'); -}; + new Promise((resolve, reject) => + { + const { request, parser: ParserCtor, select, timeoutMs = 15_000 } = config; + + let settled = false; + let timeoutHandle: ReturnType | null = null; + let listener: IMessageEvent | undefined = undefined; + + const cleanup = () => + { + if(timeoutHandle !== null) clearTimeout(timeoutHandle); + if(listener) GetCommunication().removeMessageEvent(listener); + }; + + listener = new (ParserCtor as any)((event: TParser) => + { + if(settled) return; + settled = true; + + cleanup(); + + try + { + resolve(select ? select(event) : (event)); + } + catch(err) + { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + + GetCommunication().registerMessageEvent(listener); + + timeoutHandle = setTimeout(() => + { + if(settled) return; + settled = true; + cleanup(); + reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`)); + }, timeoutMs); + + if(request) SendMessageComposer(request()); + }); diff --git a/src/components/catalog/views/targeted-offer/OfferView.tsx b/src/components/catalog/views/targeted-offer/OfferView.tsx index 4fa945b..0ec2637 100644 --- a/src/components/catalog/views/targeted-offer/OfferView.tsx +++ b/src/components/catalog/views/targeted-offer/OfferView.tsx @@ -1,27 +1,28 @@ import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer'; -import { useEffect, useState } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { useMessageEventState } from '../../../../hooks'; +import { useState } from 'react'; +import { useNitroQuery } from '../../../../api/nitro-query'; import { OfferBubbleView } from './OfferBubbleView'; import { OfferWindowView } from './OfferWindowView'; export const OfferView = () => { - const offer = useMessageEventState( - TargetedOfferEvent, - evt => evt.getParser()?.data ?? null, - null - ); + const { data: offer } = useNitroQuery({ + key: [ 'nitro', 'catalog', 'targeted-offer' ], + request: () => new GetTargetedOfferComposer(), + parser: TargetedOfferEvent, + select: evt => evt.getParser()?.data ?? null, + staleTime: Infinity + }); + const [ opened, setOpened ] = useState(false); - useEffect(() => - { - SendMessageComposer(new GetTargetedOfferComposer()); - }, []); + if(!offer) return null; - if(!offer) return; - - return <> - { opened ? : } - ; + return ( + <> + { opened + ? + : } + + ); }; diff --git a/src/index.tsx b/src/index.tsx index 40553d6..8621453 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,20 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { ErrorBoundary } from 'react-error-boundary'; import { App } from './App'; import { LoadingView } from './components/loading/LoadingView'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false + } + } +}); + import './css/index.css'; import './css/backgrounds/BackgroundsView.css'; @@ -45,16 +56,18 @@ import './css/widgets/FurnitureWidgets.css'; createRoot(document.getElementById('root')).render( - ( - - ) }> - }> - - - + + ( + + ) }> + }> + + + + ); diff --git a/yarn.lock b/yarn.lock index 62b1fef..1108213 100644 --- a/yarn.lock +++ b/yarn.lock @@ -908,6 +908,30 @@ postcss "^8.5.6" tailwindcss "4.2.4" +"@tanstack/query-core@5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4" + integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w== + +"@tanstack/query-devtools@5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz#1972789fdc7c4cb9ec2062d51f25bc4dc655a27b" + integrity sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw== + +"@tanstack/react-query-devtools@5": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz#cca3479cc2c8b434637c31f8119fe6ff93e5832c" + integrity sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg== + dependencies: + "@tanstack/query-devtools" "5.100.10" + +"@tanstack/react-query@5": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.10.tgz#3bf1844efd76f5f68f9f39da2917fc4c6023e726" + integrity sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q== + dependencies: + "@tanstack/query-core" "5.100.10" + "@tanstack/react-virtual@^3.13.24": version "3.13.24" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz#77af3d5dcf77358d805b7b3b06d3221af7bd3f6f"